1use crate::agenda::Occurrence;
29use crate::demands::{Demands, Level};
30use crate::i18n::Translator;
31use crate::tasks::{Status, Task};
32use crate::timelog::{Kind, Session};
33use fluent_bundle::FluentArgs;
34use jiff::civil::Date;
35use jiff::tz::TimeZone;
36use serde::{Deserialize, Serialize};
37use std::collections::{BTreeMap, BTreeSet};
38
39pub const SCALES: [&str; 5] = ["cognitive", "emotional", "anxiety", "body", "total"];
41pub const TOTAL: usize = 4;
43pub type Load = [f32; 5];
45
46pub const LEAST_MINUTES: u32 = 30;
49pub const FILL: f32 = 0.85;
51pub const AROUND_HEAVY: f32 = 0.75;
53pub const HEAVY_EVENT: u8 = 7;
55
56pub fn level_of(task: &Task) -> Level {
58 task.demands.level().unwrap_or_else(|| Level::of_energy(&task.energy))
59}
60
61fn rounded(x: f32) -> u8 {
62 x.round().clamp(0.0, 10.0) as u8
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct Rates {
69 pub costs: [Option<f32>; 4],
70 pub gain: Option<f32>,
71 pub word: Level,
73}
74
75impl Default for Rates {
76 fn default() -> Rates {
77 Rates { costs: [None; 4], gain: None, word: Level::Usual }
78 }
79}
80
81impl Rates {
82 pub fn of(demands: &Demands, word: Level) -> Rates {
83 Rates { costs: demands.costs().map(|c| c.map(f32::from)), gain: demands.gain.map(f32::from), word }
84 }
85
86 pub fn of_task(task: &Task) -> Rates {
88 Rates::of(&task.demands, Level::of_energy(&task.energy))
89 }
90
91 pub fn of_event(event: &Occurrence) -> Rates {
92 Rates::of(&event.demands, Level::Usual)
93 }
94
95 pub fn highest(&self) -> Option<f32> {
96 self.costs.iter().flatten().copied().fold(None, |m: Option<f32>, v| Some(m.map_or(v, |m| m.max(v))))
97 }
98
99 pub fn level(&self) -> Level {
101 match self.highest() {
102 Some(h) => Level::of(rounded(h), self.gain.map(rounded)),
103 None => self.word,
104 }
105 }
106
107 pub fn per_hour(&self) -> Load {
111 let mut load = [0.0; 5];
112 for (s, cost) in self.costs.iter().enumerate() {
113 load[s] = cost.unwrap_or(0.0);
114 }
115 load[TOTAL] = self.highest().unwrap_or_else(|| self.word.rating());
116 load
117 }
118
119 pub fn load(&self, minutes: u32) -> Load {
121 let hours = minutes.max(LEAST_MINUTES) as f32 / 60.0;
122 self.per_hour().map(|r| r * hours)
123 }
124
125 pub fn dominant(&self) -> Option<usize> {
127 let (index, value) = self.costs.iter().enumerate().filter_map(|(i, c)| c.map(|c| (i, c))).fold(None, |best: Option<(usize, f32)>, (i, c)| match best {
128 Some((_, b)) if b >= c => best,
129 _ => Some((i, c)),
130 })?;
131 (value >= 4.0).then_some(index)
132 }
133
134 pub fn known(&self) -> [bool; 5] {
136 let mut known = [false; 5];
137 for (s, cost) in self.costs.iter().enumerate() {
138 known[s] = cost.is_some();
139 }
140 known[TOTAL] = true;
141 known
142 }
143}
144
145pub fn add(a: &mut Load, b: &Load) {
147 for s in 0..5 {
148 a[s] += b[s];
149 }
150}
151
152#[derive(Debug, Clone, Default)]
155pub struct FeltIndex {
156 items: BTreeMap<String, Vec<(Option<Date>, Demands)>>,
157 kinds: BTreeMap<String, Vec<(Option<Date>, Demands)>>,
158}
159
160pub const FELT_MEDIAN_OF: usize = 5;
162
163fn item_key(task: &Task) -> String {
164 let title: String = crate::text::fold(task.title.trim()).into_iter().collect::<String>().to_lowercase();
165 if title.is_empty() { format!("uid:{}", task.uid) } else { title }
166}
167
168impl FeltIndex {
169 pub fn of(tasks: &[Task]) -> FeltIndex {
170 let mut index = FeltIndex::default();
171 let mut seen: BTreeSet<&str> = BTreeSet::new();
172 for task in tasks.iter().filter(|t| !t.felt.is_empty()) {
173 if !seen.insert(task.uid.as_str()) {
174 continue;
175 }
176 let ratings: Vec<(Option<Date>, Demands)> = task.felt.iter().map(|f| (f.on, f.demands)).collect();
177 index.items.entry(item_key(task)).or_default().extend(ratings.iter().copied());
178 if !task.kind.is_empty() {
179 index.kinds.entry(task.kind.clone()).or_default().extend(ratings);
180 }
181 }
182 for list in index.items.values_mut().chain(index.kinds.values_mut()) {
183 list.sort_by_key(|(on, _)| *on);
184 }
185 index
186 }
187
188 fn median(ratings: &[(Option<Date>, Demands)], pick: impl Fn(&Demands) -> Option<u8>) -> Option<f32> {
190 let mut last: Vec<f32> = ratings.iter().rev().filter_map(|(_, d)| pick(d)).take(FELT_MEDIAN_OF).map(f32::from).collect();
191 if last.is_empty() {
192 return None;
193 }
194 last.sort_by(f32::total_cmp);
195 let n = last.len();
196 Some(if n % 2 == 1 { last[n / 2] } else { (last[n / 2 - 1] + last[n / 2]) / 2.0 })
197 }
198
199 pub fn rates(&self, task: &Task) -> Rates {
203 let item = self.items.get(&item_key(task)).map(Vec::as_slice).unwrap_or(&[]);
204 let kind = if task.kind.is_empty() { &[][..] } else { self.kinds.get(&task.kind).map(Vec::as_slice).unwrap_or(&[]) };
205 let value = |pick: &dyn Fn(&Demands) -> Option<u8>| FeltIndex::median(item, pick).or_else(|| pick(&task.demands).map(f32::from)).or_else(|| FeltIndex::median(kind, pick));
206 let costs = [value(&|d: &Demands| d.cognitive), value(&|d: &Demands| d.emotional), value(&|d: &Demands| d.anxiety), value(&|d: &Demands| d.body)];
207 Rates { costs, gain: value(&|d: &Demands| d.gain), word: Level::of_energy(&task.energy) }
208 }
209
210 pub fn proposal(&self, task: &Task) -> Proposal {
216 let item = self.items.get(&item_key(task)).map(Vec::as_slice).unwrap_or(&[]);
217 let kind = if task.kind.is_empty() { &[][..] } else { self.kinds.get(&task.kind).map(Vec::as_slice).unwrap_or(&[]) };
218 let (mut from_item, mut from_kind) = (false, false);
219 let mut value = |pick: &dyn Fn(&Demands) -> Option<u8>| -> Option<u8> {
220 if let Some(median) = FeltIndex::median(item, pick) {
221 from_item = true;
222 return Some(rounded(median));
223 }
224 let median = FeltIndex::median(kind, pick)?;
225 from_kind = true;
226 Some(rounded(median))
227 };
228 let values = Demands {
229 cognitive: value(&|d: &Demands| d.cognitive),
230 emotional: value(&|d: &Demands| d.emotional),
231 anxiety: value(&|d: &Demands| d.anxiety),
232 body: value(&|d: &Demands| d.body),
233 gain: value(&|d: &Demands| d.gain),
234 };
235 let from = match (from_item, from_kind) {
236 (true, true) => "both",
237 (true, false) => "item",
238 (false, true) => "kind",
239 (false, false) => "",
240 };
241 Proposal { values, from }
242 }
243
244 pub fn is_empty(&self) -> bool {
246 self.items.is_empty()
247 }
248}
249
250#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
253pub struct Proposal {
254 pub values: Demands,
255 pub from: &'static str,
258}
259
260pub const PRIOR_RATIO: f32 = 1.1;
264pub const PRIOR_SIGMA: f32 = 0.5;
266pub const STANDS_FROM: f32 = 9.0;
268pub const KIND_PULL: f32 = 5.0;
270pub const RATIO_HALF_LIFE: f32 = 4.5;
272pub const TYPED_WEIGHT: f32 = 0.25;
274pub const RATIO_HORIZON: i64 = 90;
276pub const DAY_SHARE: f32 = 0.3;
278pub const DAY_PERCENTILE: f32 = 0.85;
280pub const DRAWS: usize = 2000;
282pub const Z_DAY: f32 = 1.036_433;
284pub const SLACK_SHARE: f32 = 1.0 / 3.0;
288
289#[derive(Debug, Clone, PartialEq)]
291pub struct Finished {
292 pub uid: String,
293 pub kind: &'static str,
295 pub done: Date,
296 pub log: f32,
298 pub weight: f32,
300}
301
302pub fn finished(tasks: &[Task], sessions: &[Session], kind_of: &dyn Fn(&Task) -> &'static str, zone: &TimeZone) -> Vec<Finished> {
306 let mut spent: BTreeMap<&str, (u32, f32)> = BTreeMap::new();
307 for s in sessions.iter().filter(|s| !s.task.is_empty()) {
308 let entry = spent.entry(s.task.as_str()).or_insert((0, 0.0));
309 entry.0 = entry.0.saturating_add(s.minutes);
310 entry.1 += s.minutes as f32 * if Kind::timed(s.kind) { 1.0 } else { TYPED_WEIGHT };
311 }
312 let mut seen: BTreeSet<&str> = BTreeSet::new();
313 tasks
314 .iter()
315 .filter(|t| t.status == Status::Completed && seen.insert(t.uid.as_str()))
316 .filter_map(|t| {
317 let estimate = t.estimate_first.unwrap_or(t.estimate);
318 let &(minutes, weighed) = spent.get(t.uid.as_str())?;
319 if estimate == 0 || minutes == 0 {
320 return None;
321 }
322 let done = jiff::Timestamp::from_second(t.completed?).ok()?.to_zoned(zone.clone()).date();
323 let log = (minutes as f32 / estimate as f32).ln().clamp(-(8f32.ln()), 8f32.ln());
324 Some(Finished { uid: t.uid.clone(), kind: kind_of(t), done, log, weight: weighed / minutes as f32 })
325 })
326 .collect()
327}
328
329#[derive(Debug, Clone, PartialEq)]
331pub struct Ratios {
332 pub person: f32,
334 pub count: f32,
336 pub kinds: BTreeMap<&'static str, (f32, f32)>,
338 pub sigma: f32,
340 pub today: f32,
342 pub today_sigma: f32,
343}
344
345impl Default for Ratios {
346 fn default() -> Ratios {
347 let spread = Spread::of(PRIOR_SIGMA);
348 Ratios { person: PRIOR_RATIO.ln(), count: 0.0, kinds: BTreeMap::new(), sigma: PRIOR_SIGMA, today: 0.0, today_sigma: spread.day }
349 }
350}
351
352fn weighted_quantile(mut values: Vec<(f32, f32)>, q: f32) -> Option<f32> {
354 values.retain(|(_, w)| *w > 0.0);
355 if values.is_empty() {
356 return None;
357 }
358 values.sort_by(|a, b| a.0.total_cmp(&b.0));
359 let total: f32 = values.iter().map(|v| v.1).sum();
360 let mut running = 0.0;
361 for &(value, weight) in &values {
362 running += weight;
363 if running >= q * total - 1e-6 {
364 return Some(value);
365 }
366 }
367 values.last().map(|v| v.0)
368}
369
370fn median_of(mut values: Vec<f32>) -> f32 {
371 if values.is_empty() {
372 return 0.0;
373 }
374 values.sort_by(f32::total_cmp);
375 let n = values.len();
376 if n % 2 == 1 { values[n / 2] } else { (values[n / 2 - 1] + values[n / 2]) / 2.0 }
377}
378
379fn winsorised(logs: &[f32]) -> Vec<f32> {
382 if logs.len() < 5 {
383 return logs.to_vec();
384 }
385 let centre = median_of(logs.to_vec());
386 let mad = 1.4826 * median_of(logs.iter().map(|y| (y - centre).abs()).collect());
387 if mad <= 0.0 {
388 return logs.to_vec();
389 }
390 logs.iter().map(|y| y.clamp(centre - 3.0 * mad, centre + 3.0 * mad)).collect()
391}
392
393pub fn ratios(finished: &[Finished], today: Date) -> Ratios {
395 let age = |d: Date| d.until(today).map_or(0, |s| i64::from(s.get_days())).max(0);
396 let recent: Vec<&Finished> = finished.iter().filter(|f| age(f.done) <= RATIO_HORIZON && f.done <= today).collect();
397 let logs = winsorised(&recent.iter().map(|f| f.log).collect::<Vec<_>>());
398 let weight = |f: &Finished| 0.5f32.powf(age(f.done) as f32 / RATIO_HALF_LIFE) * f.weight;
399 let mean = |pick: &dyn Fn(&Finished) -> bool| -> Option<(f32, f32)> {
401 let (mut sum, mut weights, mut count) = (0.0, 0.0, 0.0);
402 for (f, y) in recent.iter().zip(&logs).filter(|(f, _)| pick(f)) {
403 let w = weight(f);
404 sum += w * y;
405 weights += w;
406 count += f.weight;
407 }
408 (weights > 0.0).then(|| (sum / weights, count))
409 };
410 let prior = PRIOR_RATIO.ln();
411 let mut out = Ratios::default();
412 if let Some((mean, count)) = mean(&|_| true) {
413 let w = (count / STANDS_FROM).min(1.0);
414 out.person = w * mean + (1.0 - w) * prior;
415 out.count = count;
416 }
417 let kinds: BTreeSet<&'static str> = recent.iter().map(|f| f.kind).collect();
418 for kind in kinds {
419 if let Some((mean, count)) = mean(&|f| f.kind == kind) {
420 let w = count / (count + KIND_PULL);
421 out.kinds.insert(kind, (w * mean + (1.0 - w) * out.person, count));
422 }
423 }
424 let residuals: Vec<f32> = recent.iter().zip(&logs).map(|(f, y)| y - out.log_of(f.kind)).collect();
426 let n = residuals.len() as f32;
427 let sigma = if residuals.len() >= 10 { (1.4826 * median_of(residuals.iter().map(|r| r.abs()).collect())).clamp(0.2, 1.0) } else { PRIOR_SIGMA };
428 out.sigma = sigma * (1.0 + 1.0 / n.max(1.0)).sqrt();
429 let spread = Spread::of(out.sigma);
431 let today_residuals: Vec<f32> = recent.iter().zip(&logs).filter(|(f, _)| f.done == today).map(|(f, y)| y - out.log_of(f.kind)).collect();
432 let k = today_residuals.len() as f32;
433 if k > 0.0 {
434 let ratio = spread.item * spread.item / (spread.day * spread.day);
435 out.today = today_residuals.iter().sum::<f32>() / (k + ratio);
436 out.today_sigma = (1.0 / (1.0 / (spread.day * spread.day) + k / (spread.item * spread.item))).sqrt();
437 } else {
438 out.today_sigma = spread.day;
439 }
440 out
441}
442
443impl Ratios {
444 pub fn log_of(&self, kind: &str) -> f32 {
446 self.kinds.get(kind).map_or(self.person, |k| k.0)
447 }
448
449 pub fn factor(&self, kind: &str) -> f32 {
450 self.log_of(kind).exp()
451 }
452
453 pub fn corrected(&self, estimate: u32, kind: &str) -> Option<u32> {
457 (estimate > 0).then(|| ((estimate as f32 * self.factor(kind)).round() as u32).max(1))
458 }
459
460 pub fn today_factor(&self) -> f32 {
464 self.today.max(0.0).exp()
465 }
466
467 pub fn line(&self, kind: &str, tr: &Translator) -> String {
470 let Some(&(log, count)) = self.kinds.get(kind) else { return String::new() };
471 let factor = log.exp();
472 if count < 5.0 || (factor - 1.0).abs() < 0.15 {
473 return String::new();
474 }
475 let mut args = FluentArgs::new();
476 args.set("factor", format!("{:.1}", (factor * 10.0).round() / 10.0).replace('.', if tr.language() == "fr" { "," } else { "." }));
477 tr.text(if factor > 1.0 { "capacity-ratio-longer" } else { "capacity-ratio-shorter" }, Some(&args))
478 }
479}
480
481#[derive(Debug, Clone, Copy, PartialEq)]
483pub struct Spread {
484 pub day: f32,
485 pub item: f32,
486}
487
488impl Spread {
489 pub fn of(sigma: f32) -> Spread {
490 Spread { day: (DAY_SHARE).sqrt() * sigma, item: (1.0 - DAY_SHARE).sqrt() * sigma }
491 }
492}
493
494struct Draws(u64);
496
497impl Draws {
498 fn next(&mut self) -> u64 {
499 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
500 let mut z = self.0;
501 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
502 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
503 z ^ (z >> 31)
504 }
505
506 fn uniform(&mut self) -> f64 {
508 ((self.next() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
509 }
510
511 fn normal(&mut self) -> f32 {
513 let (u, v) = (self.uniform(), self.uniform());
514 ((-2.0 * u.ln()).sqrt() * (std::f64::consts::TAU * v).cos()) as f32
515 }
516}
517
518pub fn slack_estimate(sum: f32, squares: f32, spread: Spread, effect: f32) -> f32 {
525 if sum <= 0.0 {
526 return 0.0;
527 }
528 let (day, item) = (spread.day * spread.day, spread.item * spread.item);
529 let mean = (effect + (day + item) / 2.0).exp() * sum;
530 let second = (2.0 * effect).exp() * (squares * (2.0 * day + 2.0 * item).exp() + (sum * sum - squares).max(0.0) * (2.0 * day + item).exp());
532 let variance = (second - mean * mean).max(0.0);
533 let s2 = (1.0 + variance / (mean * mean)).ln();
534 let mu = mean.ln() - s2 / 2.0;
535 ((mu + Z_DAY * s2.sqrt()).exp() - sum).max(0.0)
536}
537
538pub fn seed(date: Date, uids: &[&str]) -> u64 {
540 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
541 for byte in date.to_string().bytes().chain(uids.iter().flat_map(|u| u.bytes().chain([0]))) {
542 hash ^= u64::from(byte);
543 hash = hash.wrapping_mul(0x0100_0000_01b3);
544 }
545 hash
546}
547
548pub fn slack(medians: &[u32], spread: Spread, effect: f32, seed: u64) -> u32 {
553 let sum: f32 = medians.iter().map(|&m| m as f32).sum();
554 if medians.is_empty() || sum <= 0.0 {
555 return 0;
556 }
557 let mut draws = Draws(seed);
558 let mut totals: Vec<f32> = (0..DRAWS)
559 .map(|_| {
560 let day = effect + spread.day * draws.normal();
561 medians.iter().map(|&m| m as f32 * (day + spread.item * draws.normal()).exp()).sum::<f32>()
562 })
563 .collect();
564 totals.sort_by(f32::total_cmp);
565 let p = totals[((DAY_PERCENTILE * DRAWS as f32) as usize).min(DRAWS - 1)];
566 (p - sum).max(0.0).round() as u32
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(rename_all = "snake_case")]
574pub enum Outcome {
575 TooMuch,
576 AboutRight,
577 TooEmpty,
578}
579
580impl Outcome {
581 pub fn bad(self) -> bool {
582 self == Outcome::TooMuch
583 }
584
585 pub fn fine(self) -> bool {
587 !self.bad()
588 }
589
590 pub fn good(self) -> bool {
592 self == Outcome::AboutRight
593 }
594}
595
596#[derive(Debug, Clone, PartialEq)]
598pub struct DayRecord {
599 pub date: Date,
600 pub load: Load,
601 pub known: [bool; 5],
603 pub good_hours: f32,
605 pub gain_known: bool,
607 pub outcome: Option<Outcome>,
608}
609
610pub const GAIN_HOURS_CAP: f32 = 2.0;
612
613impl DayRecord {
614 pub fn new(date: Date) -> DayRecord {
615 DayRecord { date, load: [0.0; 5], known: [false; 5], good_hours: 0.0, gain_known: false, outcome: None }
616 }
617
618 pub fn count(&mut self, rates: &Rates, minutes: u32) {
620 if minutes == 0 {
621 return;
622 }
623 add(&mut self.load, &rates.load(minutes));
624 for (s, known) in rates.known().iter().enumerate() {
625 self.known[s] |= *known;
626 }
627 if let Some(gain) = rates.gain {
628 self.gain_known = true;
629 self.good_hours += gain / 10.0 * (minutes as f32 / 60.0).min(GAIN_HOURS_CAP);
630 }
631 }
632
633 pub fn is_empty(&self) -> bool {
635 !self.known[TOTAL] && self.outcome.is_none()
636 }
637}
638
639#[allow(clippy::too_many_arguments)]
650pub fn day_records(tasks: &[Task], sessions: &[Session], events: &[Occurrence], index: &FeltIndex, corrected: &dyn Fn(&Task) -> u32, outcomes: &BTreeMap<Date, Outcome>, from: Date, to: Date, zone: &TimeZone) -> Vec<DayRecord> {
651 let day_of = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()).date());
652 let by_uid: BTreeMap<&str, &Task> = tasks.iter().map(|t| (t.uid.as_str(), t)).collect();
653 let mut days: BTreeMap<Date, DayRecord> = BTreeMap::new();
654 fn record(days: &mut BTreeMap<Date, DayRecord>, date: Date, from: Date, to: Date) -> Option<&mut DayRecord> {
655 (date >= from && date <= to).then(|| days.entry(date).or_insert_with(|| DayRecord::new(date)))
656 }
657 let rates_on = |task: &Task, date: Date| -> Rates {
659 let planned = index.rates(task);
660 match task.felt.iter().find(|f| f.on == Some(date)) {
661 Some(felt) => {
662 let said = Rates::of(&felt.demands, planned.word);
663 Rates { costs: std::array::from_fn(|s| said.costs[s].or(planned.costs[s])), gain: said.gain.or(planned.gain), word: planned.word }
664 }
665 None => planned,
666 }
667 };
668 let mut worked: BTreeSet<&str> = BTreeSet::new();
669 let mut minutes: BTreeMap<(Date, &str), u32> = BTreeMap::new();
670 for s in sessions {
671 let Some(date) = day_of(s.start) else { continue };
672 if !s.task.is_empty() {
673 worked.insert(s.task.as_str());
674 }
675 let key = (date, if s.task.is_empty() { "" } else { s.task.as_str() });
676 *minutes.entry(key).or_default() += s.minutes;
677 }
678 for ((date, uid), m) in minutes {
679 let Some(day) = record(&mut days, date, from, to) else { continue };
680 match by_uid.get(uid) {
681 Some(task) => day.count(&rates_on(task, date), m),
682 None => day.count(&Rates::default(), m),
683 }
684 }
685 for task in tasks.iter().filter(|t| t.status == Status::Completed && !worked.contains(t.uid.as_str())) {
686 let Some(date) = task.completed.and_then(day_of) else { continue };
687 let Some(day) = record(&mut days, date, from, to) else { continue };
688 day.count(&rates_on(task, date), corrected(task));
689 }
690 for event in events.iter().filter(|e| e.holds_time() && !e.all_day && e.end > e.start) {
692 let Some(date) = day_of(event.start) else { continue };
693 let Some(day) = record(&mut days, date, from, to) else { continue };
694 let length = u32::try_from((event.end - event.start) / 60).unwrap_or(0).min(24 * 60);
695 day.count(&Rates::of_event(event), length);
696 }
697 for (&date, &outcome) in outcomes.range(from..=to) {
698 if let Some(day) = record(&mut days, date, from, to) {
699 day.outcome = Some(outcome);
700 }
701 }
702 days.into_values().filter(|d| !d.is_empty()).collect()
703}
704
705#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
709#[serde(rename_all = "kebab-case")]
710pub enum Start {
711 #[default]
713 AsNow,
714 Lighter,
716 MuchLighter,
718}
719
720impl Start {
721 pub fn share(self) -> f32 {
722 match self {
723 Start::AsNow => 1.0,
724 Start::Lighter => 0.75,
725 Start::MuchLighter => 0.5,
726 }
727 }
728
729 pub fn parse(text: &str) -> Start {
730 match text {
731 "lighter" => Start::Lighter,
732 "much-lighter" => Start::MuchLighter,
733 _ => Start::AsNow,
734 }
735 }
736}
737
738pub fn start_budget(room: u32, start: Start) -> Load {
744 let hours = room as f32 / 60.0;
745 let total = (Level::Usual.rating() * hours + 2.0 * (Level::Heavy.rating() - Level::Usual.rating())) / FILL * start.share();
746 [total.max(1.0); 5]
747}
748
749pub const BUDGET_HALF_LIFE: f32 = 5.0;
751pub const INFORMATIVE: f32 = 0.7;
753pub const FULL: f32 = 0.8;
755pub const DROP: f32 = 0.8;
757pub const RISE: f32 = 1.1;
759pub const BLAME_DAYS: i64 = 2;
761pub const STABLE_DAYS: usize = 7;
763pub const FLARE: usize = 3;
765pub const FULL_WEIGHT_DAYS: f32 = 14.0;
767pub const FLOOR: f32 = 0.25;
769pub const WINSOR: f32 = 1.2;
771pub const CAP_DAYS: i64 = 30;
773pub const CAP_FROM: usize = 3;
775pub const GAIN_DEFAULT: f32 = 1.0;
777pub const GAIN_LOWEST: f32 = 0.5;
778pub const GAIN_HIGHEST: f32 = 4.0;
779pub const GAIN_FROM: usize = 5;
781pub const GAIN_PERCENTILE: f32 = 0.25;
783pub const WINDOW_DEFAULT: u32 = 28;
785pub const WINDOW_LEAST: u32 = 14;
786pub const WINDOW_MOST: u32 = 90;
787
788#[derive(Debug, Clone, Copy, PartialEq)]
790pub struct Change {
791 pub date: Date,
792 pub scale: usize,
793 pub factor: f32,
795}
796
797#[derive(Debug, Clone, PartialEq)]
799pub struct Learned {
800 pub budget: Load,
801 pub start: Load,
802 pub cap: [Option<f32>; 5],
804 pub frozen: bool,
806 pub minimum: f32,
808 pub days: usize,
810 pub changes: Vec<Change>,
811}
812
813fn days_between(a: Date, b: Date) -> i64 {
814 a.until(b).map_or(0, |s| i64::from(s.get_days()))
815}
816
817pub fn learn(records: &[DayRecord], start: Load, window: u32, today: Date) -> Learned {
824 let window = i64::from(window.clamp(WINDOW_LEAST, WINDOW_MOST));
825 let records: Vec<&DayRecord> = records.iter().filter(|r| r.date < today).collect();
826 let at: BTreeMap<Date, usize> = records.iter().enumerate().map(|(i, r)| (r.date, i)).collect();
827 let floor = start.map(|b| b * FLOOR);
828 let mut budget = start;
829 let mut in_force: Vec<Load> = Vec::with_capacity(records.len());
830 let mut last_change: [Option<Date>; 5] = [None; 5];
831 let mut blamed: BTreeSet<(Date, usize)> = BTreeSet::new();
832 let mut frozen = false;
833 let mut minimum = GAIN_DEFAULT;
834 let mut changes: Vec<Change> = Vec::new();
835 let mut with_outcome: Vec<usize> = Vec::new();
836 let used = |r: &DayRecord, b: &Load, s: usize| if b[s] > 0.0 { r.load[s] / b[s] } else { 0.0 };
837 for (i, record) in records.iter().enumerate() {
838 in_force.push(budget);
839 let Some(outcome) = record.outcome else { continue };
840 with_outcome.push(i);
841 let d = record.date;
842 if outcome.bad() {
844 for s in 0..5 {
845 for back in 1..=BLAME_DAYS {
846 let Some(&j) = d.checked_sub(jiff::Span::new().days(back)).ok().and_then(|p| at.get(&p)) else { continue };
847 let prior = records[j];
848 if prior.known[s] && used(prior, &in_force[j], s) >= FULL && blamed.insert((prior.date, s)) {
849 let before = budget[s];
850 budget[s] = (budget[s] * DROP).max(floor[s]);
851 last_change[s] = Some(d);
852 changes.push(Change { date: d, scale: s, factor: budget[s] / before });
853 break;
854 }
855 }
856 }
857 }
858 let recent_bad = with_outcome.iter().filter(|&&j| days_between(records[j].date, d) < 7 && records[j].outcome.is_some_and(Outcome::bad)).count();
860 let last_week: Vec<usize> = with_outcome.iter().rev().take(STABLE_DAYS).copied().collect();
861 if recent_bad >= FLARE {
862 frozen = true;
863 } else if frozen && last_week.len() == STABLE_DAYS && last_week.iter().all(|&j| records[j].outcome.is_some_and(Outcome::fine)) {
864 frozen = false;
865 }
866 let in_window = |j: usize| days_between(records[j].date, d) < window;
868 let outcome_days = with_outcome.iter().filter(|&&j| in_window(j)).count() as f32;
869 let alpha = (outcome_days / FULL_WEIGHT_DAYS).min(1.0);
870 for s in 0..5 {
871 if frozen || last_change[s].is_some_and(|c| days_between(c, d) < STABLE_DAYS as i64) {
872 continue;
873 }
874 let stable = last_week.len() == STABLE_DAYS
875 && last_week.iter().all(|&j| in_window(j) && records[j].outcome.is_some_and(Outcome::fine))
876 && last_week.iter().map(|&j| used(records[j], &in_force[j], s)).sum::<f32>() / STABLE_DAYS as f32 >= FULL;
877 if !stable {
878 continue;
879 }
880 let (mut sum, mut weights) = (0.0, 0.0);
881 for &j in with_outcome.iter().filter(|&&j| in_window(j)) {
882 let r = records[j];
883 if r.known[s] && r.outcome.is_some_and(Outcome::fine) && used(r, &in_force[j], s) >= INFORMATIVE {
884 let w = 0.5f32.powf(days_between(r.date, d) as f32 / BUDGET_HALF_LIFE);
885 sum += w * r.load[s].min(WINSOR * in_force[j][s]);
886 weights += w;
887 }
888 }
889 if weights <= 0.0 {
890 continue;
891 }
892 let target = (1.0 - alpha) * start[s] + alpha * sum / weights;
893 if target > budget[s] {
894 let before = budget[s];
895 budget[s] = target.min(before * RISE);
896 last_change[s] = Some(d);
897 changes.push(Change { date: d, scale: s, factor: budget[s] / before });
898 }
899 }
900 let good: Vec<(f32, f32)> = with_outcome
902 .iter()
903 .filter(|&&j| in_window(j))
904 .map(|&j| records[j])
905 .filter(|r| r.outcome.is_some_and(Outcome::good) && r.gain_known)
906 .map(|r| (r.good_hours, 0.5f32.powf(days_between(r.date, d) as f32 / BUDGET_HALF_LIFE)))
907 .collect();
908 let candidate = if good.len() < GAIN_FROM { GAIN_DEFAULT } else { weighted_quantile(good, GAIN_PERCENTILE).unwrap_or(GAIN_DEFAULT).clamp(GAIN_LOWEST, GAIN_HIGHEST) };
909 let bad_stretch = last_week.iter().filter(|&&j| !records[j].outcome.is_some_and(Outcome::good)).count() >= FLARE;
910 if !(candidate < minimum && bad_stretch) {
911 minimum = candidate;
912 }
913 }
914 let cap = std::array::from_fn(|s| {
916 let fine: Vec<f32> = records.iter().filter(|r| days_between(r.date, today) <= CAP_DAYS && r.known[s] && r.load[s] > 0.0 && r.outcome.is_some_and(Outcome::fine)).map(|r| r.load[s]).collect();
917 (fine.len() >= CAP_FROM).then(|| fine.iter().copied().fold(0.0, f32::max) * RISE)
918 });
919 let days = with_outcome.iter().filter(|&&j| days_between(records[j].date, today) <= window).count();
920 Learned { budget, start, cap, frozen, minimum, days, changes }
921}
922
923impl Learned {
924 pub fn limit(&self) -> Load {
926 std::array::from_fn(|s| self.cap[s].map_or(self.budget[s], |cap| self.budget[s].min(cap)))
927 }
928}
929
930#[derive(Debug, Clone, Default)]
935pub struct Planning {
936 pub rates: BTreeMap<String, Rates>,
938 pub corrected: BTreeMap<String, u32>,
940 pub held: BTreeMap<Date, Load>,
942 pub lightened: BTreeSet<Date>,
944 pub limit: Option<Load>,
946 pub even: bool,
948 pub spread: Option<Spread>,
950 pub today_effect: f32,
952 pub today_sigma: f32,
953 pub gain_slot: u32,
955}
956
957impl Planning {
958 pub fn rates_of(&self, task: &Task) -> Rates {
960 self.rates.get(&task.uid).copied().unwrap_or_else(|| Rates::of_task(task))
961 }
962
963 pub fn level_of(&self, task: &Task) -> Level {
964 self.rates.get(&task.uid).map_or_else(|| level_of(task), Rates::level)
965 }
966
967 pub fn day_limit(&self, date: Date, share: f32) -> Option<Load> {
969 let around = if self.lightened.contains(&date) { AROUND_HEAVY } else { 1.0 };
970 self.limit.map(|l| l.map(|b| b * FILL * share * around))
971 }
972}
973
974pub fn around_heavy(events: &[Occurrence], zone: &TimeZone, today: Date) -> BTreeSet<Date> {
976 let mut out = BTreeSet::new();
977 for event in events.iter().filter(|e| e.holds_time() && e.demands.highest().is_some_and(|h| h >= HEAVY_EVENT)) {
978 let Some(first) = jiff::Timestamp::from_second(event.start).ok().map(|t| t.to_zoned(zone.clone()).date()) else { continue };
979 let last = jiff::Timestamp::from_second((event.end - 1).max(event.start)).ok().map_or(first, |t| t.to_zoned(zone.clone()).date());
980 for date in [first.yesterday().ok(), last.tomorrow().ok()].into_iter().flatten() {
981 if date >= today {
982 out.insert(date);
983 }
984 }
985 }
986 out
987}
988
989pub fn held_by_events(events: &[Occurrence], zone: &TimeZone) -> BTreeMap<Date, Load> {
991 let mut out: BTreeMap<Date, Load> = BTreeMap::new();
992 for event in events.iter().filter(|e| e.holds_time() && !e.all_day && e.end > e.start) {
993 let Some(date) = jiff::Timestamp::from_second(event.start).ok().map(|t| t.to_zoned(zone.clone()).date()) else { continue };
994 let minutes = u32::try_from((event.end - event.start) / 60).unwrap_or(0).min(24 * 60);
995 add(out.entry(date).or_insert([0.0; 5]), &Rates::of_event(event).load(minutes));
996 }
997 out
998}
999
1000pub const HISTORY_DAYS: i64 = 180;
1004pub const AHEAD_DAYS: i64 = 28;
1006pub const GAIN_SLOT_MINUTES: u32 = 30;
1008
1009pub fn outcomes_of(reviews: &crate::reviews::Reviews) -> BTreeMap<Date, Outcome> {
1011 use crate::reviews::Mix;
1012 reviews
1013 .days
1014 .iter()
1015 .filter_map(|(date, day)| {
1016 let outcome = match day.outcome()? {
1017 Mix::TooMuch => Outcome::TooMuch,
1018 Mix::AboutRight => Outcome::AboutRight,
1019 Mix::TooEmpty => Outcome::TooEmpty,
1020 };
1021 Some((*date, outcome))
1022 })
1023 .collect()
1024}
1025
1026pub fn ratio_kind(areas: &crate::areas::TaskAreas, task: &Task) -> &'static str {
1029 let area = areas.of(task);
1030 if area.work {
1031 "work"
1032 } else if area.admin {
1033 "admin"
1034 } else {
1035 "leisure"
1036 }
1037}
1038
1039#[derive(Debug, Clone)]
1041pub struct Record {
1042 pub index: FeltIndex,
1043 pub ratios: Ratios,
1044 pub learned: Learned,
1045 pub records: Vec<DayRecord>,
1047 pub outcomes: BTreeMap<Date, Outcome>,
1048 pub planning: Planning,
1050 pub heavy_near: Option<(String, bool)>,
1052 pub window: u32,
1054 pub default_estimate: u32,
1056 pub kinds: BTreeMap<String, &'static str>,
1058}
1059
1060fn midnight_of(date: Date, zone: &TimeZone) -> i64 {
1061 date.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second())
1062}
1063
1064pub fn gather(tasks: &[Task], sessions: &[Session], settings: &crate::plan::Settings, choices: &crate::config::PlanningSettings, events: &dyn Fn(i64, i64) -> Vec<Occurrence>, now: &jiff::Zoned) -> Record {
1071 let zone = now.time_zone().clone();
1072 let today = now.date();
1073 let index = FeltIndex::of(tasks);
1074 let kind = |t: &Task| ratio_kind(&settings.areas, t);
1075 let ratios = ratios(&finished(tasks, sessions, &kind, &zone), today);
1076 let length = |t: &Task| ratios.corrected(t.estimate, kind(t)).unwrap_or(if t.estimate > 0 { t.estimate } else { settings.default_estimate });
1077 let from = today.checked_sub(jiff::Span::new().days(HISTORY_DAYS)).unwrap_or(today);
1078 let outcomes = outcomes_of(&crate::reviews::Reviews::load_between(&crate::reviews::Reviews::default_path(), from, today));
1079 let records = match outcomes.keys().next() {
1080 Some(first) => {
1081 let start = first.checked_sub(jiff::Span::new().days(BLAME_DAYS)).unwrap_or(*first).max(from);
1082 let past = events(midnight_of(start, &zone), midnight_of(today, &zone));
1083 day_records(tasks, sessions, &past, &index, &length, &outcomes, start, today.yesterday().unwrap_or(today), &zone)
1084 }
1085 None => Vec::new(),
1086 };
1087 let room = settings.week.iter().map(crate::plan::Room::total).max().unwrap_or(0);
1088 let learned = learn(&records, start_budget(room, choices.start()), choices.window(), today);
1089 let midnight = midnight_of(today, &zone);
1091 let ahead = events(midnight - 86_400, midnight + AHEAD_DAYS * 86_400);
1092 let lightened = around_heavy(&ahead, &zone, today);
1093 let heavy = |e: &&Occurrence| !e.cancelled && e.demands.highest().is_some_and(|h| h >= HEAVY_EVENT);
1094 let day_of = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()).date());
1095 let heavy_near = ahead
1096 .iter()
1097 .filter(heavy)
1098 .find_map(|e| (day_of(e.start) == today.tomorrow().ok()).then(|| (e.summary.clone(), true)))
1099 .or_else(|| ahead.iter().filter(heavy).find_map(|e| (day_of((e.end - 1).max(e.start)) == today.yesterday().ok()).then(|| (e.summary.clone(), false))));
1100 let open: Vec<&Task> = tasks.iter().filter(|t| t.status.is_open()).collect();
1101 let planning = Planning {
1102 rates: open.iter().map(|t| (t.uid.clone(), index.rates(t))).collect(),
1103 corrected: open.iter().filter_map(|t| Some((t.uid.clone(), ratios.corrected(t.estimate, kind(t))?))).collect(),
1104 held: held_by_events(&ahead, &zone),
1105 lightened,
1106 limit: Some(learned.limit()),
1107 even: choices.even_days,
1108 spread: Some(Spread::of(ratios.sigma)),
1109 today_effect: ratios.today,
1110 today_sigma: ratios.today_sigma,
1111 gain_slot: if choices.gain_slots() { GAIN_SLOT_MINUTES } else { 0 },
1112 };
1113 Record { index, ratios, learned, records, outcomes, planning, heavy_near, window: choices.window(), default_estimate: settings.default_estimate, kinds: tasks.iter().map(|t| (t.uid.clone(), kind(t))).collect() }
1114}
1115
1116impl Record {
1117 fn length(&self, task: &Task) -> u32 {
1119 let kind = self.kinds.get(&task.uid).copied().unwrap_or("leisure");
1120 self.ratios.corrected(task.estimate, kind).unwrap_or(if task.estimate > 0 { task.estimate } else { self.default_estimate })
1121 }
1122
1123 pub fn ratio_line(&self, task: &Task, tr: &Translator) -> String {
1125 self.ratios.line(self.kinds.get(&task.uid).copied().unwrap_or("leisure"), tr)
1126 }
1127
1128 pub fn day_balance(&self, tasks: &[Task], sessions: &[Session], events: &[Occurrence], date: Date, zone: &TimeZone, tr: &Translator) -> DayBalance {
1133 let day = day_records(tasks, sessions, events, &self.index, &|t| self.length(t), &self.outcomes, date, date, zone).pop().unwrap_or_else(|| DayRecord { outcome: self.outcomes.get(&date).copied(), ..DayRecord::new(date) });
1134 let before: Vec<DayRecord> = self.records.iter().filter(|r| r.date < date).cloned().collect();
1135 let learned = learn(&before, self.learned.start, self.window, date);
1136 let mut with: Vec<DayRecord> = before;
1137 with.push(day.clone());
1138 let after = date.tomorrow().ok().map(|next| learn(&with, self.learned.start, self.window, next));
1139 balance(&day, &learned, after.as_ref().filter(|_| day.outcome.is_some()), tr)
1140 }
1141
1142 pub fn reasons(&self, today: Date, tr: &Translator) -> Vec<String> {
1144 reasons(&self.learned, today, &self.planning.lightened, self.heavy_near.as_ref().map(|(t, before)| (t.as_str(), *before)), tr)
1145 }
1146}
1147
1148#[derive(Debug, Clone, PartialEq, Serialize)]
1152pub struct ScaleBalance {
1153 pub scale: &'static str,
1155 pub level: &'static str,
1157 pub load: f32,
1159 pub budget: f32,
1160}
1161
1162#[derive(Debug, Clone, PartialEq, Serialize)]
1164pub struct GainBalance {
1165 pub level: &'static str,
1167 pub good_hours: f32,
1169 pub minimum: f32,
1170}
1171
1172#[derive(Debug, Clone, PartialEq, Serialize)]
1175pub struct DayBalance {
1176 pub date: String,
1177 pub costs: Vec<ScaleBalance>,
1178 pub gain: GainBalance,
1179 pub said: Vec<String>,
1181}
1182
1183pub const LIGHT_DAY: f32 = 0.5;
1185pub const HEAVY_DAY: f32 = 1.0;
1186
1187pub fn balance(record: &DayRecord, learned: &Learned, after: Option<&Learned>, tr: &Translator) -> DayBalance {
1190 let limit = learned.limit();
1191 let costs = (0..5)
1192 .map(|s| {
1193 let used = if limit[s] > 0.0 { record.load[s] / limit[s] } else { 0.0 };
1194 let level = if !record.known[s] {
1195 ""
1196 } else if used > HEAVY_DAY {
1197 "heavy"
1198 } else if used <= LIGHT_DAY && !record.outcome.is_some_and(Outcome::bad) {
1199 "light"
1200 } else {
1201 "usual"
1202 };
1203 ScaleBalance { scale: SCALES[s], level, load: record.load[s], budget: limit[s] }
1204 })
1205 .collect();
1206 let minimum = learned.minimum;
1207 let gain_level = if !record.gain_known {
1208 ""
1209 } else if record.good_hours < 0.75 * minimum {
1210 "below"
1211 } else if record.good_hours > 1.25 * minimum {
1212 "above"
1213 } else {
1214 "around"
1215 };
1216 let mut said = Vec::new();
1217 if let Some(after) = after {
1218 if (0..5).any(|s| after.budget[s] < learned.budget[s] * 0.99) {
1219 said.push(tr.text("capacity-said-tomorrow-less", None));
1220 }
1221 if after.frozen && !learned.frozen {
1222 said.push(tr.text("capacity-said-held", None));
1223 }
1224 }
1225 said.truncate(2);
1226 DayBalance { date: record.date.to_string(), costs, gain: GainBalance { level: gain_level, good_hours: record.good_hours, minimum }, said }
1227}
1228
1229pub fn reasons(learned: &Learned, today: Date, lightened: &BTreeSet<Date>, heavy_event: Option<(&str, bool)>, tr: &Translator) -> Vec<String> {
1234 let mut out = Vec::new();
1235 let yesterday = today.yesterday().unwrap_or(today);
1236 if learned.changes.iter().any(|c| c.date == yesterday && c.factor < 1.0) {
1237 out.push(tr.text("capacity-reason-after-bad", None));
1238 }
1239 if lightened.contains(&today)
1240 && let Some((title, before)) = heavy_event
1241 {
1242 let mut args = FluentArgs::new();
1243 args.set("title", title.to_string());
1244 out.push(tr.text(if before { "capacity-reason-before-heavy" } else { "capacity-reason-after-heavy" }, Some(&args)));
1245 }
1246 let fortnight = today.checked_sub(jiff::Span::new().days(14)).unwrap_or(today);
1248 let product: f32 = learned.changes.iter().filter(|c| c.scale == TOTAL && c.date > fortnight).map(|c| c.factor).product();
1249 if product >= 1.3 {
1250 out.push(tr.text("capacity-reason-more", None));
1251 } else if product <= 1.0 / 1.3 && out.is_empty() {
1252 out.push(tr.text("capacity-reason-less", None));
1253 }
1254 out.truncate(2);
1255 out
1256}
1257
1258pub fn suggestion(tasks: &[Task], index: &FeltIndex, today: Date) -> Option<String> {
1264 let mut seen: BTreeSet<String> = BTreeSet::new();
1265 let mut liked: Vec<&Task> = tasks
1266 .iter()
1267 .filter(|t| !t.felt.is_empty())
1268 .filter(|t| {
1269 let item = index.items.get(&item_key(t)).map(Vec::as_slice).unwrap_or(&[]);
1270 let gain = FeltIndex::median(item, |d| d.gain);
1271 let costs = [FeltIndex::median(item, |d| d.cognitive), FeltIndex::median(item, |d| d.emotional), FeltIndex::median(item, |d| d.anxiety), FeltIndex::median(item, |d| d.body)];
1272 gain.is_some_and(|g| g >= 6.0) && costs.iter().flatten().all(|c| *c <= 3.0)
1273 })
1274 .filter(|t| seen.insert(item_key(t)))
1275 .collect();
1276 if liked.is_empty() {
1277 return None;
1278 }
1279 liked.sort_by_key(|t| item_key(t));
1280 let day = Date::constant(2000, 1, 1).until(today).map_or(0, |s| s.get_days().unsigned_abs() as usize);
1281 Some(liked[day % liked.len()].title.clone())
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use crate::demands::Felt;
1288
1289 fn day(text: &str) -> Date {
1290 text.parse().unwrap()
1291 }
1292
1293 fn rated(c: Option<u8>, e: Option<u8>, a: Option<u8>, b: Option<u8>, g: Option<u8>) -> Demands {
1294 Demands { cognitive: c, emotional: e, anxiety: a, body: b, gain: g }
1295 }
1296
1297 #[test]
1298 fn loads_rating_by_hours_half_an_hour_at_least() {
1299 let call = Rates::of(&rated(None, None, Some(8), None, None), Level::Usual);
1300 assert_eq!(call.load(10), [0.0, 0.0, 4.0, 0.0, 4.0]);
1302 assert_eq!(Rates::of(&Demands::default(), Level::Heavy).load(60), [0.0, 0.0, 0.0, 0.0, 7.0]);
1304 assert_eq!(Rates::of(&Demands::default(), Level::Usual).load(90), [0.0, 0.0, 0.0, 0.0, 6.0]);
1305 assert_eq!(Rates::of(&Demands::default(), Level::Rest).load(60)[TOTAL], 2.0, "what gives back is not free");
1306 let mixed = Rates::of(&rated(Some(5), Some(2), Some(3), Some(1), None), Level::Usual);
1308 assert_eq!(mixed.load(60), [5.0, 2.0, 3.0, 1.0, 5.0]);
1309 assert_eq!((mixed.dominant(), Rates::of(&rated(Some(3), Some(2), None, None, None), Level::Light).dominant()), (Some(0), None));
1310 }
1311
1312 fn felt_task(uid: &str, title: &str, kind: &str, forecast: Demands, felt: &[(&str, Demands)]) -> Task {
1313 Task { uid: uid.into(), title: title.into(), kind: kind.into(), demands: forecast, felt: felt.iter().map(|(on, d)| Felt { on: Some(day(on)), demands: *d }).collect(), ..Task::default() }
1314 }
1315
1316 #[test]
1317 fn felt_ratings_plan_over_forecasts() {
1318 let anxious = rated(None, None, Some(9), None, None);
1319 let history: Vec<(&str, Demands)> = [("2026-09-01", 3), ("2026-09-08", 4), ("2026-09-15", 2), ("2026-09-22", 5), ("2026-09-29", 3), ("2026-10-06", 8)].iter().map(|(d, a)| (*d, rated(None, None, Some(*a), None, None))).collect();
1321 let old = felt_task("old", "Call the bank", "call", anxious, &history);
1322 let new = felt_task("new", " call the BANK ", "call", anxious, &[]);
1323 let other = felt_task("other", "Call the school", "call", rated(Some(2), None, None, None, None), &[]);
1324 let index = FeltIndex::of(&[old, new.clone(), other.clone()]);
1325 assert_eq!(index.rates(&new).costs[2], Some(4.0));
1327 let rates = index.rates(&other);
1329 assert_eq!((rates.costs[0], rates.costs[2]), (Some(2.0), Some(4.0)));
1330 assert_eq!(FeltIndex::default().rates(&new).costs[2], Some(9.0));
1332 assert_eq!(index.rates(&new).level(), Level::Usual, "felt: usual, though forecast heavy");
1333 }
1334
1335 #[test]
1336 fn proposals_come_from_what_was_felt_never_from_the_forecast() {
1337 let old = felt_task("old", "Call the bank", "call", rated(None, None, Some(9), None, None), &[("2026-09-01", rated(None, None, Some(3), None, Some(6))), ("2026-09-08", rated(None, None, Some(4), None, None))]);
1339 let school = felt_task("school", "Call the school", "call", Demands::default(), &[("2026-09-02", rated(Some(2), None, Some(8), None, None))]);
1340 let new = felt_task("new", "call the bank", "call", rated(Some(7), None, Some(9), None, None), &[]);
1341 let index = FeltIndex::of(&[old, school, new.clone()]);
1342 let proposal = index.proposal(&new);
1344 assert_eq!(proposal.values, rated(Some(2), None, Some(4), None, Some(6)));
1345 assert_eq!(proposal.from, "both");
1346 let plumber = felt_task("plumber", "Call the plumber", "call", Demands::default(), &[]);
1348 assert_eq!(index.proposal(&plumber), Proposal { values: rated(Some(2), None, Some(4), None, Some(6)), from: "kind" });
1349 let letter = felt_task("letter", "Write to the school", "write", rated(Some(5), None, None, None, None), &[]);
1351 assert_eq!(index.proposal(&letter), Proposal::default());
1352 let bare = felt_task("bare", "Call the bank", "", Demands::default(), &[]);
1354 assert_eq!(index.proposal(&bare).from, "item");
1355 assert_eq!(index.proposal(&bare).values.cognitive, None, "no kind, nothing from calls");
1356 assert_eq!(serde_json::to_value(index.proposal(&bare)).unwrap(), serde_json::json!({ "values": { "cognitive": null, "emotional": null, "anxiety": 4, "body": null, "gain": 6 }, "from": "item" }));
1357 }
1358
1359 fn done(uid: &str, estimate: u32, first: Option<u32>, on: &str, area: &str) -> Task {
1360 let completed = day(on).at(17, 0, 0, 0).to_zoned(TimeZone::UTC).unwrap().timestamp().as_second();
1361 Task { uid: uid.into(), title: uid.into(), estimate, estimate_first: first, status: Status::Completed, completed: Some(completed), area: area.into(), ..Task::default() }
1362 }
1363
1364 fn session(uid: &str, on: &str, minutes: u32, kind: Option<Kind>) -> Session {
1365 Session { task: uid.into(), start: day(on).at(9, 0, 0, 0).to_zoned(TimeZone::UTC).unwrap().timestamp().as_second(), minutes, kind, ..Session::default() }
1366 }
1367
1368 fn area(task: &Task) -> &'static str {
1369 match task.area.as_str() {
1370 "work" => "work",
1371 "admin" => "admin",
1372 _ => "leisure",
1373 }
1374 }
1375
1376 #[test]
1377 fn the_ratio_learns_from_finished_timed_tasks() {
1378 let today = day("2026-10-06");
1379 let mut tasks = Vec::new();
1381 let mut sessions = Vec::new();
1382 for i in 0..12 {
1383 let on = format!("2026-10-{:02}", 1 + i % 5);
1384 tasks.push(done(&format!("a{i}"), 60, Some(30), &on, "admin"));
1385 sessions.push(session(&format!("a{i}"), &on, 45, Some(Kind::Measured)));
1386 }
1387 let all = finished(&tasks, &sessions, &area, &TimeZone::UTC);
1388 assert_eq!(all.len(), 12);
1389 assert!((all[0].log - 1.5f32.ln()).abs() < 1e-5, "the first estimate, not the current one: {}", all[0].log);
1390 let r = ratios(&all, today);
1391 assert!((r.person.exp() - 1.5).abs() < 0.01, "stands alone past nine tasks: {}", r.person.exp());
1392 assert!((r.factor("admin") - 1.5).abs() < 0.02, "{}", r.factor("admin"));
1393 assert_eq!(r.corrected(40, "admin"), Some(60));
1394 assert_eq!(r.corrected(0, "admin"), None, "no estimate of its own: never invented");
1395 assert!((r.factor("work") - r.person.exp()).abs() < 1e-6);
1397 let three = ratios(&all[..3], today);
1399 let expected = (1.0 / 3.0) * 1.5f32.ln() + (2.0 / 3.0) * 1.1f32.ln();
1400 assert!((three.person - expected).abs() < 1e-4, "{} vs {expected}", three.person);
1401 let mut more = tasks.clone();
1403 let mut more_sessions = sessions.clone();
1404 for i in 0..4 {
1405 more.push(done(&format!("w{i}"), 30, Some(30), "2026-10-05", "work"));
1406 more_sessions.push(session(&format!("w{i}"), "2026-10-05", 60, Some(Kind::Measured)));
1407 }
1408 let r = ratios(&finished(&more, &more_sessions, &area, &TimeZone::UTC), today);
1409 let work = r.factor("work");
1410 assert!(work < 2.0 && work > r.person.exp(), "work between yours and its own: {work}");
1411 }
1412
1413 #[test]
1414 fn typed_time_counts_little_and_open_or_dropped_tasks_not_at_all() {
1415 let today = day("2026-10-06");
1416 let mut tasks = vec![done("typed", 30, Some(30), "2026-10-05", "admin"), done("timed", 30, Some(30), "2026-10-05", "admin")];
1417 let mut open = done("open", 30, Some(30), "2026-10-05", "admin");
1418 open.status = Status::NeedsAction;
1419 let mut dropped = done("dropped", 30, Some(30), "2026-10-05", "admin");
1420 dropped.status = Status::Cancelled;
1421 tasks.extend([open, dropped]);
1422 let sessions = vec![session("typed", "2026-10-05", 90, Some(Kind::Typed)), session("timed", "2026-10-05", 30, Some(Kind::Corrected)), session("open", "2026-10-05", 300, Some(Kind::Measured)), session("dropped", "2026-10-05", 300, Some(Kind::Measured)), session("typed", "2026-10-05", 0, None)];
1423 let all = finished(&tasks, &sessions, &area, &TimeZone::UTC);
1424 assert_eq!(all.iter().map(|f| f.uid.as_str()).collect::<Vec<_>>(), vec!["typed", "timed"], "only finished tasks");
1425 assert_eq!((all[0].weight, all[1].weight), (TYPED_WEIGHT, 1.0));
1426 let r = ratios(&all, today);
1427 let mean = (0.25 * 3f32.ln() + 1.0 * 0.0) / 1.25;
1429 let w = 1.25 / STANDS_FROM;
1430 assert!((r.person - (w * mean + (1.0 - w) * 1.1f32.ln())).abs() < 1e-4, "{}", r.person);
1431 let unknown = finished(&[done("u", 30, None, "2026-10-05", "admin")], &[session("u", "2026-10-05", 60, None)], &area, &TimeZone::UTC);
1433 assert_eq!(unknown[0].weight, TYPED_WEIGHT);
1434 }
1435
1436 #[test]
1437 fn recent_tasks_weigh_more() {
1438 let today = day("2026-10-20");
1439 let mut tasks = Vec::new();
1441 let mut sessions = Vec::new();
1442 for i in 0..20 {
1443 tasks.push(done(&format!("o{i}"), 30, Some(30), "2026-09-20", "admin"));
1444 sessions.push(session(&format!("o{i}"), "2026-09-20", 60, Some(Kind::Measured)));
1445 }
1446 for i in 0..10 {
1447 tasks.push(done(&format!("n{i}"), 30, Some(30), "2026-10-19", "admin"));
1448 sessions.push(session(&format!("n{i}"), "2026-10-19", 30, Some(Kind::Measured)));
1449 }
1450 let r = ratios(&finished(&tasks, &sessions, &area, &TimeZone::UTC), today);
1451 assert!(r.person.exp() < 1.05, "a month old weighs almost nothing at a half-life of 4.5 days: {}", r.person.exp());
1452 }
1453
1454 #[test]
1455 fn today_is_weighed_by_its_finished_tasks() {
1456 let today = day("2026-10-06");
1457 let mut tasks = Vec::new();
1458 let mut sessions = Vec::new();
1459 for i in 0..10 {
1460 tasks.push(done(&format!("p{i}"), 30, Some(30), "2026-10-02", "admin"));
1461 sessions.push(session(&format!("p{i}"), "2026-10-02", 30, Some(Kind::Measured)));
1462 }
1463 let calm = ratios(&finished(&tasks, &sessions, &area, &TimeZone::UTC), today);
1464 assert_eq!((calm.today, calm.today_factor()), (0.0, 1.0));
1465 for i in 0..2 {
1467 tasks.push(done(&format!("t{i}"), 30, Some(30), "2026-10-06", "admin"));
1468 sessions.push(session(&format!("t{i}"), "2026-10-06", 60, Some(Kind::Measured)));
1469 }
1470 let slow = ratios(&finished(&tasks, &sessions, &area, &TimeZone::UTC), today);
1471 assert!(slow.today > 0.0 && slow.today_factor() < 2.0 && slow.today_sigma < Spread::of(slow.sigma).day, "{slow:?}");
1472 }
1473
1474 #[test]
1475 fn slack_at_p85_is_deterministic() {
1476 let spread = Spread::of(PRIOR_SIGMA);
1477 let medians = [30, 30, 30, 30, 30];
1478 let a = slack(&medians, spread, 0.0, seed(day("2026-10-06"), &["a", "b"]));
1479 let b = slack(&medians, spread, 0.0, seed(day("2026-10-06"), &["a", "b"]));
1480 assert_eq!(a, b, "the same day, the same steps: the same slack");
1481 assert!((30..=110).contains(&a), "{a}");
1483 assert!(slack(&medians, Spread::of(0.8), 0.0, 1) > slack(&medians, Spread::of(0.3), 0.0, 1));
1485 assert_eq!(slack(&[], spread, 0.0, 1), 0);
1486 assert!(slack(&medians, spread, 0.3, 7) > slack(&medians, spread, 0.0, 7));
1488 assert_ne!(seed(day("2026-10-06"), &["a"]), seed(day("2026-10-07"), &["a"]));
1489 }
1490
1491 #[test]
1492 fn the_estimate_follows_the_simulation() {
1493 for (medians, sigma, effect) in [(vec![30u32, 30, 30, 30, 30], 0.5f32, 0.0f32), (vec![240, 17], 0.707, 0.0), (vec![15, 15, 15], 0.707, 0.0), (vec![90, 45, 20], 0.3, 0.2), (vec![180], 0.5, 0.0)] {
1495 let spread = Spread::of(sigma);
1496 let sum: f32 = medians.iter().map(|&m| m as f32).sum();
1497 let squares: f32 = medians.iter().map(|&m| (m * m) as f32).sum();
1498 let simulated = slack(&medians, spread, effect, 42) as f32;
1499 let estimated = slack_estimate(sum, squares, spread, effect);
1500 assert!((estimated - simulated).abs() <= (0.1 * simulated).max(3.0), "{medians:?} σ {sigma}: {estimated} against {simulated}");
1501 }
1502 assert_eq!(slack_estimate(0.0, 0.0, Spread::of(0.5), 0.0), 0.0);
1503 }
1504
1505 fn days(from: &str, n: i64, load: f32, outcome: Option<Outcome>) -> Vec<DayRecord> {
1507 (0..n)
1508 .map(|i| {
1509 let date = day(from).checked_add(jiff::Span::new().days(i)).unwrap();
1510 DayRecord { date, load: [load; 5], known: [true; 5], good_hours: 1.0, gain_known: true, outcome }
1511 })
1512 .collect()
1513 }
1514
1515 fn after(records: &[DayRecord]) -> Date {
1516 records.last().unwrap().date.tomorrow().unwrap()
1517 }
1518
1519 const START: Load = [10.0; 5];
1520
1521 #[test]
1522 fn rises_only_after_seven_stable_full_days() {
1523 let six = days("2026-09-01", 6, 9.0, Some(Outcome::AboutRight));
1525 assert_eq!(learn(&six, START, 28, after(&six)).budget[0], 10.0);
1526 let seven = days("2026-09-01", 7, 12.0, Some(Outcome::AboutRight));
1528 let learned = learn(&seven, START, 28, after(&seven));
1529 assert!((learned.budget[0] - 11.0).abs() < 1e-4, "{:?}", learned.budget);
1530 let mild = days("2026-09-01", 10, 7.5, Some(Outcome::AboutRight));
1532 assert_eq!(learn(&mild, START, 28, after(&mild)).budget[0], 10.0);
1533 let light = days("2026-09-01", 20, 3.0, Some(Outcome::AboutRight));
1535 assert_eq!(learn(&light, START, 28, after(&light)).budget, START);
1536 let fourteen = days("2026-09-01", 14, 14.0, Some(Outcome::AboutRight));
1538 let learned = learn(&fourteen, START, 28, after(&fourteen));
1539 assert!((learned.budget[0] - 12.1).abs() < 1e-3, "two rises in two weeks: {:?}", learned.budget);
1540 }
1541
1542 #[test]
1543 fn falls_a_fifth_after_a_bad_day_following_a_full_one() {
1544 let mut record = days("2026-09-01", 3, 5.0, None);
1545 record[1].load = [9.0; 5]; record[2].outcome = Some(Outcome::TooMuch);
1547 let learned = learn(&record, START, 28, after(&record));
1548 assert!((learned.budget[0] - 8.0).abs() < 1e-5, "{:?}", learned.budget);
1549 assert_eq!(learned.changes.len(), 5);
1550 let mut light = days("2026-09-01", 3, 5.0, None);
1552 light[2].outcome = Some(Outcome::TooMuch);
1553 assert_eq!(learn(&light, START, 28, after(&light)).budget, START);
1554 let mut twice = days("2026-09-01", 4, 5.0, None);
1556 twice[1].load = [9.0; 5];
1557 twice[2].outcome = Some(Outcome::TooMuch);
1558 twice[3].outcome = Some(Outcome::TooMuch);
1559 assert!((learn(&twice, START, 28, after(&twice)).budget[0] - 8.0).abs() < 1e-5);
1560 let mut one = days("2026-09-01", 3, 5.0, None);
1562 one[1].load = [2.0, 9.0, 2.0, 2.0, 9.0];
1563 one[2].outcome = Some(Outcome::TooMuch);
1564 let learned = learn(&one, START, 28, after(&one));
1565 assert_eq!((learned.budget[0], learned.budget[1]), (10.0, 8.0));
1566 let mut crash = Vec::new();
1568 for k in 0..20 {
1569 let mut pair = days(&day("2026-09-01").checked_add(jiff::Span::new().days(2 * k)).unwrap().to_string(), 2, 100.0, None);
1570 pair[1].outcome = Some(Outcome::TooMuch);
1571 crash.extend(pair);
1572 }
1573 assert!((learn(&crash, START, 90, after(&crash)).budget[0] - 2.5).abs() < 1e-5);
1574 }
1575
1576 #[test]
1577 fn freezes_after_three_bad_days_in_seven() {
1578 let mut record = days("2026-09-01", 7, 9.0, Some(Outcome::AboutRight));
1580 for date in ["2026-09-08", "2026-09-10", "2026-09-12"] {
1581 record.push(DayRecord { outcome: Some(Outcome::TooMuch), ..days(date, 1, 2.0, None).remove(0) });
1582 }
1583 record.extend(days("2026-09-13", 6, 12.0, Some(Outcome::AboutRight)));
1584 let learned = learn(&record, START, 28, after(&record));
1585 assert!(learned.frozen, "six good days after a flare: still held");
1586 let before = learned.budget[0];
1587 record.extend(days("2026-09-19", 1, 12.0, Some(Outcome::AboutRight)));
1589 let learned = learn(&record, START, 28, after(&record));
1590 assert!(!learned.frozen);
1591 assert!(learned.budget[0] >= before);
1592 }
1593
1594 #[test]
1595 fn the_cap_and_the_window() {
1596 let mut record = days("2026-09-20", 3, 4.0, Some(Outcome::AboutRight));
1598 record[1].load = [6.0; 5];
1599 let learned = learn(&record, START, 28, day("2026-09-25"));
1600 assert!((learned.cap[0].unwrap() - 6.6).abs() < 1e-4);
1601 assert!((learned.limit()[0] - 6.6).abs() < 1e-4, "the budget under its cap");
1602 assert_eq!(learn(&record[..2], START, 28, day("2026-09-25")).cap[0], None);
1604 assert_eq!(learn(&record, START, 28, day("2026-11-05")).cap[0], None);
1606 let mut sparse = days("2026-09-01", 7, 12.0, Some(Outcome::AboutRight));
1608 sparse.extend(days("2026-09-08", 20, 2.0, None));
1609 let learned = learn(&sparse, START, 28, after(&sparse));
1610 assert_eq!((learned.days, learned.budget[0] > 10.0), (7, true), "missing days count for nothing");
1611 let short = learn(&sparse, START, 14, day("2026-10-20"));
1612 assert_eq!(short.days, 0, "out of a 14-day window");
1613 }
1614
1615 #[test]
1616 fn learned_weight_grows_to_fourteen_days() {
1617 let record = days("2026-09-01", 10, 15.0, Some(Outcome::AboutRight));
1619 let learned = learn(&record, START, 28, after(&record));
1620 assert!((learned.budget[0] - 11.0).abs() < 1e-4, "{:?}", learned.budget);
1622 }
1623
1624 #[test]
1625 fn the_gain_minimum() {
1626 let mut record = days("2026-09-01", 4, 1.0, Some(Outcome::AboutRight));
1627 assert_eq!(learn(&record, START, 28, after(&record)).minimum, GAIN_DEFAULT);
1629 record = days("2026-09-01", 6, 1.0, Some(Outcome::AboutRight));
1631 for (k, r) in record.iter_mut().enumerate() {
1632 r.good_hours = 2.0 + k as f32 * 0.5;
1633 }
1634 let learned = learn(&record, START, 28, after(&record));
1635 assert!(learned.minimum >= 2.5 && learned.minimum <= 3.5, "{}", learned.minimum);
1636 let mut high = days("2026-09-01", 8, 1.0, Some(Outcome::AboutRight));
1638 high.iter_mut().for_each(|r| r.good_hours = 9.0);
1639 assert_eq!(learn(&high, START, 28, after(&high)).minimum, GAIN_HIGHEST);
1640 let mut low = days("2026-09-01", 8, 1.0, Some(Outcome::AboutRight));
1641 low.iter_mut().for_each(|r| r.good_hours = 0.1);
1642 assert_eq!(learn(&low, START, 28, after(&low)).minimum, GAIN_LOWEST);
1643 let mut stretch = days("2026-09-01", 6, 1.0, Some(Outcome::AboutRight));
1645 stretch.iter_mut().for_each(|r| r.good_hours = 3.0);
1646 let kept = learn(&stretch, START, 28, after(&stretch)).minimum;
1647 stretch.extend(days("2026-09-07", 3, 1.0, Some(Outcome::TooMuch)));
1648 let mut thin = days("2026-09-10", 3, 1.0, Some(Outcome::AboutRight));
1649 thin.iter_mut().for_each(|r| r.good_hours = 0.2);
1650 stretch.extend(thin);
1651 assert_eq!(learn(&stretch, START, 28, after(&stretch)).minimum, kept);
1652 }
1653
1654 #[test]
1655 fn the_start_is_as_now_by_default() {
1656 let budget = start_budget(480, Start::AsNow);
1658 let full_day = 8.0 * 4.0 + 2.0 * 3.0;
1659 assert!((budget[TOTAL] * FILL - full_day).abs() < 1e-3);
1660 assert!((start_budget(480, Start::MuchLighter)[TOTAL] - budget[TOTAL] / 2.0).abs() < 1e-3);
1661 assert_eq!((Start::parse("lighter"), Start::parse("?")), (Start::Lighter, Start::AsNow));
1662 }
1663
1664 #[test]
1665 fn day_records_from_sessions_completions_and_events() {
1666 let zone = TimeZone::UTC;
1667 let on = day("2026-10-05");
1668 let at = |h: i8| on.at(h, 0, 0, 0).to_zoned(zone.clone()).unwrap().timestamp().as_second();
1669 let mut report = Task { uid: "report".into(), title: "Report".into(), demands: rated(Some(6), None, None, None, Some(4)), ..Task::default() };
1670 report.felt = vec![Felt { on: Some(on), demands: rated(Some(8), None, None, None, None) }];
1671 let quick = Task { uid: "quick".into(), title: "Quick".into(), estimate: 20, status: Status::Completed, completed: Some(at(15)), energy: "light".into(), ..Task::default() };
1672 let sessions = vec![Session { task: "report".into(), start: at(9), minutes: 90, kind: Some(Kind::Measured), ..Session::default() }, Session { project: "lumen".into(), start: at(11), minutes: 60, ..Session::default() }];
1673 let dentist = Occurrence { start: at(16), end: at(17), demands: rated(None, None, Some(7), Some(5), None), ..Occurrence::default() };
1674 let lunch = Occurrence { start: at(12), end: at(13), all_day: true, ..Occurrence::default() };
1675 let outcomes = BTreeMap::from([(on, Outcome::TooMuch)]);
1676 let tasks = vec![report, quick];
1677 let index = FeltIndex::of(&tasks);
1678 let records = day_records(&tasks, &sessions, &[dentist, lunch], &index, &|t| t.estimate.max(30), &outcomes, on, on, &zone);
1679 assert_eq!(records.len(), 1);
1680 let r = &records[0];
1681 assert_eq!(r.load[0], 12.0);
1683 assert_eq!(r.load[2], 7.0);
1684 assert_eq!(r.load[3], 5.0);
1685 assert_eq!(r.load[TOTAL], 12.0 + 4.0 + 1.0 + 7.0);
1686 assert_eq!(r.known, [true, false, true, true, true]);
1687 assert!((r.good_hours - 0.4 * 1.5).abs() < 1e-5 && r.gain_known);
1688 assert_eq!(r.outcome, Some(Outcome::TooMuch));
1689 }
1690
1691 #[test]
1692 fn a_balance_in_words_only() {
1693 let tr = Translator::new("en");
1694 let learned = Learned { budget: [10.0; 5], start: [10.0; 5], cap: [None; 5], frozen: false, minimum: 1.0, days: 10, changes: Vec::new() };
1695 let record = DayRecord { date: day("2026-10-05"), load: [2.0, 12.0, 6.0, 0.0, 12.0], known: [true, true, true, false, true], good_hours: 0.2, gain_known: true, outcome: Some(Outcome::AboutRight) };
1696 let balance = balance(&record, &learned, None, &tr);
1697 let levels: Vec<(&str, &str)> = balance.costs.iter().map(|c| (c.scale, c.level)).collect();
1698 assert_eq!(levels, vec![("cognitive", "light"), ("emotional", "heavy"), ("anxiety", "usual"), ("body", ""), ("total", "heavy")]);
1699 assert_eq!(balance.gain.level, "below");
1700 let unknown = DayRecord { gain_known: false, ..record.clone() };
1701 assert_eq!(super::balance(&unknown, &learned, None, &tr).gain.level, "", "nothing carried a gain: not known");
1702 let bad = DayRecord { outcome: Some(Outcome::TooMuch), ..record };
1704 assert_eq!(super::balance(&bad, &learned, None, &tr).costs[0].level, "usual");
1705 let lower = Learned { budget: [8.0; 5], ..learned.clone() };
1707 let said = super::balance(&bad, &learned, Some(&lower), &tr).said;
1708 assert_eq!(said.len(), 1);
1709 assert!(!said[0].chars().any(|c| c.is_ascii_digit()) && !said[0].contains('%'), "{said:?}");
1710 }
1711
1712 #[test]
1713 fn heavy_events_lighten_the_days_around() {
1714 let zone = TimeZone::UTC;
1715 let at = |d: &str, h: i8| day(d).at(h, 0, 0, 0).to_zoned(zone.clone()).unwrap().timestamp().as_second();
1716 let court = Occurrence { start: at("2026-10-08", 10), end: at("2026-10-08", 12), demands: rated(None, Some(8), Some(9), None, None), ..Occurrence::default() };
1717 let coffee = Occurrence { start: at("2026-10-12", 10), end: at("2026-10-12", 11), demands: rated(Some(2), None, None, None, Some(7)), ..Occurrence::default() };
1718 let around = around_heavy(&[court, coffee], &zone, day("2026-10-06"));
1719 assert_eq!(around, BTreeSet::from([day("2026-10-07"), day("2026-10-09")]));
1720 let planning = Planning { limit: Some([10.0; 5]), lightened: around, ..Planning::default() };
1721 assert_eq!(planning.day_limit(day("2026-10-07"), 1.0).unwrap()[0], 10.0 * FILL * AROUND_HEAVY);
1722 assert_eq!(planning.day_limit(day("2026-10-08"), 0.6).unwrap()[0], 10.0 * FILL * 0.6);
1723 assert_eq!(Planning::default().day_limit(day("2026-10-08"), 1.0), None);
1724 }
1725
1726 #[test]
1727 fn suggestions_from_your_own_well_rated_items_rotated() {
1728 let walk = felt_task("w", "Walk by the river", "", Demands::default(), &[("2026-10-01", rated(Some(1), None, None, Some(2), Some(8)))]);
1729 let music = felt_task("m", "Play the piano", "", Demands::default(), &[("2026-10-02", rated(Some(2), None, None, None, Some(7)))]);
1730 let party = felt_task("p", "Party", "", Demands::default(), &[("2026-10-03", rated(None, Some(6), None, Some(7), Some(9)))]);
1731 let meh = felt_task("x", "Television", "", Demands::default(), &[("2026-10-03", rated(Some(0), None, None, None, Some(4)))]);
1732 let tasks = vec![walk, music, party, meh];
1733 let index = FeltIndex::of(&tasks);
1734 let first = suggestion(&tasks, &index, day("2026-10-06")).unwrap();
1735 let second = suggestion(&tasks, &index, day("2026-10-07")).unwrap();
1736 assert_ne!(first, second, "rotated by day");
1737 for d in ["2026-10-06", "2026-10-07", "2026-10-08"] {
1738 let s = suggestion(&tasks, &index, day(d)).unwrap();
1739 assert!(s == "Walk by the river" || s == "Play the piano", "only light, well-rated items: {s}");
1740 }
1741 assert_eq!(suggestion(&[], &FeltIndex::default(), day("2026-10-06")), None);
1742 }
1743}