1use crate::agenda::Occurrence;
51use crate::areas::{Area, TaskAreas, Time, Week, in_view};
52use crate::tasks::{Status, Task};
53use crate::window::AdminWindow;
54use jiff::civil::Date;
55use jiff::tz::TimeZone;
56use jiff::{Span, Zoned};
57use serde::Serialize;
58use std::cmp::Reverse;
59use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)]
63#[serde(rename_all = "lowercase")]
64pub enum Column {
65 #[default]
67 Ready,
68 Doing,
70 Waiting,
72 Done,
74}
75
76pub const SHORTEST: u32 = 15;
78
79pub const PAUSE: u32 = 5;
81
82pub const WHOLE: u32 = 60;
84
85#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct Room(pub Vec<(Area, u32)>);
89
90impl Room {
91 pub fn for_kinds(&self, kinds: Area) -> u32 {
93 self.0.iter().filter(|(open, _)| open.meets(kinds)).map(|(_, minutes)| minutes).sum()
94 }
95
96 pub fn total(&self) -> u32 {
98 self.0.iter().map(|(_, minutes)| minutes).sum()
99 }
100
101 pub fn of(stretches: &[(i64, i64, Area)]) -> Room {
103 let mut room: Vec<(Area, u32)> = Vec::new();
104 for &(from, to, open) in stretches {
105 let minutes = u32::try_from((to - from) / 60).unwrap_or(0);
106 match room.iter_mut().find(|(kinds, _)| *kinds == open) {
107 Some(found) => found.1 += minutes,
108 None => room.push((open, minutes)),
109 }
110 }
111 Room(room)
112 }
113}
114
115pub fn stretches(windows: &[AdminWindow], date: Date, zone: &TimeZone, anything: bool) -> Vec<(i64, i64, Area)> {
119 let spans: Vec<(i64, i64, Area)> = crate::window::kinds_on(windows, date, zone)
120 .into_iter()
121 .map(|(opening, closing, kind)| (opening.timestamp().as_second(), closing.timestamp().as_second(), if anything { Area::ALL } else { Area::parse(kind).unwrap_or(Area::WORK) }))
122 .collect();
123 let mut cuts: Vec<i64> = spans.iter().flat_map(|s| [s.0, s.1]).collect();
124 cuts.sort_unstable();
125 cuts.dedup();
126 let mut out: Vec<(i64, i64, Area)> = Vec::new();
127 for pair in cuts.windows(2) {
128 let (from, to) = (pair[0], pair[1]);
129 let open = spans.iter().filter(|s| s.0 <= from && to <= s.1).fold(Area::default(), |kinds, s| kinds.with(s.2));
130 if open.is_empty() {
131 continue;
132 }
133 match out.last_mut() {
134 Some(last) if last.1 == from && last.2 == open => last.1 = to,
135 _ => out.push((from, to, open)),
136 }
137 }
138 out
139}
140
141pub fn less(stretches: &[(i64, i64, Area)], busy: &[(i64, i64)]) -> Vec<(i64, i64, Area)> {
143 let mut out = Vec::new();
144 for &(from, to, open) in stretches {
145 let mut pieces = vec![(from, to)];
146 for &(taken_from, taken_to) in busy {
147 pieces = pieces
148 .into_iter()
149 .flat_map(|(a, b)| {
150 if taken_to <= a || taken_from >= b {
151 return vec![(a, b)];
152 }
153 [(a, taken_from), (taken_to, b)].into_iter().filter(|(x, y)| y > x).collect()
154 })
155 .collect();
156 }
157 out.extend(pieces.into_iter().map(|(a, b)| (a, b, open)));
158 }
159 out
160}
161
162pub fn event_spans(events: &[Occurrence], pause: u32) -> Vec<(i64, i64)> {
167 let pause = i64::from(pause) * 60;
168 events
169 .iter()
170 .filter(|e| e.holds_time() && !e.all_day)
171 .map(|e| (e.start - i64::from(e.margins.before) * 60 - pause, e.end.max(e.start + 15 * 60) + i64::from(e.margins.after) * 60 + pause))
172 .collect()
173}
174
175#[derive(Debug, Clone)]
177pub struct Settings {
178 pub week: [Room; 7],
180 pub days: BTreeMap<Date, Room>,
183 pub windows: Vec<AdminWindow>,
185 pub anything: bool,
187 pub hours: Week,
189 pub areas: TaskAreas,
191 pub pause: u32,
193 pub default_estimate: u32,
195 pub today_percent: u32,
197 pub closed: BTreeSet<Date>,
199 pub office_days: [bool; 7],
201 pub office_hours: Vec<AdminWindow>,
204 pub heavy_today: u32,
206 pub heavy_per_day: u32,
207 pub needs: crate::needs::Needs,
209 pub shifts: BTreeMap<String, i64>,
211 pub needs_days: crate::needs::Days,
213 pub capacity: crate::capacity::Planning,
216 pub extension: Option<(i64, i64)>,
220 pub light_only: u32,
222 pub pins: BTreeMap<String, crate::blocks::Pin>,
225 pub records: BTreeMap<String, (i64, i64)>,
228}
229
230impl Default for Settings {
231 fn default() -> Settings {
233 Settings::of_hours(&[], TaskAreas::usual())
234 }
235}
236
237impl Settings {
238 pub fn of_hours(windows: &[AdminWindow], areas: TaskAreas) -> Settings {
241 let windows: Vec<AdminWindow> = windows.iter().filter(|w| w.kind() != "leisure").cloned().collect();
243 let anything = windows.is_empty();
244 let windows = if anything { crate::window::default_office_hours() } else { windows };
245 let hours = Week { work_hours: windows.iter().any(|w| w.kind() == "work"), admin_hours: windows.iter().any(|w| w.kind() == "admin"), ..Week::default() };
246 let week = std::array::from_fn(|d| Room::of(&stretches(&windows, Date::constant(2024, 1, 1 + d as i8), &TimeZone::UTC, anything)));
248 Settings {
249 week,
250 days: BTreeMap::new(),
251 windows,
252 anything,
253 hours,
254 areas,
255 pause: PAUSE,
256 default_estimate: 30,
257 today_percent: 100,
258 closed: BTreeSet::new(),
259 office_days: [true, true, true, true, true, false, false],
260 office_hours: crate::window::default_office_hours(),
261 heavy_today: 2,
262 heavy_per_day: 2,
263 needs: crate::needs::Needs::default(),
264 shifts: BTreeMap::new(),
265 needs_days: crate::needs::Days::default(),
266 capacity: crate::capacity::Planning::default(),
267 extension: None,
268 light_only: 0,
269 pins: BTreeMap::new(),
270 records: BTreeMap::new(),
271 }
272 }
273
274 pub fn flat(minutes: [u32; 7]) -> Settings {
276 let week = minutes.map(|m| if m == 0 { Room::default() } else { Room(vec![(Area::ALL, m)]) });
277 Settings { week, pause: 0, ..Settings::default() }
278 }
279
280 pub fn with_needs(mut self, needs: &crate::needs::Needs, shifts: BTreeMap<String, i64>) -> Settings {
284 let none = |_: &str| 0;
285 self.week = std::array::from_fn(|d| {
287 let date = Date::constant(2024, 1, 1 + d as i8);
288 Room::of(&less(&stretches(&self.windows, date, &TimeZone::UTC, self.anything), &needs.busy_on(date, &TimeZone::UTC, &none)))
289 });
290 self.needs = needs.clone();
291 self.shifts = shifts;
292 self
293 }
294
295 pub fn with_days(mut self, days: crate::needs::Days) -> Settings {
298 self.needs_days = days;
299 self
300 }
301
302 pub fn with_pins(mut self, blocks: crate::blocks::Blocks) -> Settings {
307 self.pins = blocks.pins;
308 self.records = blocks.records;
309 self
310 }
311
312 pub fn kept_on(&self, date: Date, zone: &TimeZone, today: Date) -> Vec<(i64, i64)> {
315 self.kept_held(date, zone, today, &[])
316 }
317
318 fn kept_held(&self, date: Date, zone: &TimeZone, today: Date, held: &[(i64, i64)]) -> Vec<(i64, i64)> {
321 let pushed = if date == today { self.shifts.clone() } else { self.needs.past_events_on(date, zone, &self.needs_days, held) };
322 self.needs.kept_with(date, zone, &self.needs_days, &|key: &str| pushed.get(key).copied().unwrap_or(0)).into_iter().map(|k| (k.start, k.end)).collect()
323 }
324
325 pub fn with_extension(mut self, extension: Option<(i64, i64)>) -> Settings {
328 self.extension = extension.filter(|(from, to)| to > from);
329 self
330 }
331
332 pub fn extension_on(&self, date: Date, zone: &TimeZone) -> Vec<(i64, i64)> {
335 self.extra_on(date, zone, &stretches(&self.windows, date, zone, self.anything)).into_iter().map(|(a, b, _)| (a, b)).collect()
336 }
337
338 fn extra_on(&self, date: Date, zone: &TimeZone, hours: &[(i64, i64, Area)]) -> Vec<(i64, i64, Area)> {
339 let Some((from, to)) = self.extension else { return Vec::new() };
340 if jiff::Timestamp::from_second(from).ok().map(|t| t.to_zoned(zone.clone()).date()) != Some(date) {
341 return Vec::new();
342 }
343 less(&[(from, to, Area::WORK)], &hours.iter().map(|h| (h.0, h.1)).collect::<Vec<_>>())
344 }
345
346 pub fn hours_on(&self, date: Date, zone: &TimeZone) -> Vec<(i64, i64, Area)> {
348 let mut hours = stretches(&self.windows, date, zone, self.anything);
349 hours.extend(self.extra_on(date, zone, &hours));
350 hours.sort_by_key(|h| h.0);
351 hours
352 }
353
354 pub fn with_events(mut self, now: &Zoned, events: &[Occurrence]) -> Settings {
357 let zone = now.time_zone().clone();
358 let today = now.date();
359 let mut busy = event_spans(events, self.pause);
360 let pause = i64::from(self.pause) * 60;
362 busy.extend(self.pins.values().map(|pin| (pin.span().0 - pause, pin.span().1 + pause)));
363 let day_of = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()).date());
364 let horizon = add_days(today, 3 * 366);
367 let mut dates: BTreeSet<Date> = BTreeSet::from([today]);
368 for &(from, to) in &busy {
369 let (Some(first), Some(last)) = (day_of(from), day_of(to)) else { continue };
370 let mut date = first.yesterday().unwrap_or(first).max(today);
371 while date <= last.min(horizon) {
372 dates.insert(date);
373 let Ok(next) = date.tomorrow() else { break };
374 date = next;
375 }
376 }
377 for &date in self.needs_days.0.keys().filter(|d| **d >= today.yesterday().unwrap_or(today) && **d <= horizon) {
379 dates.extend([date, date.tomorrow().unwrap_or(date)].into_iter().filter(|d| *d >= today));
380 }
381 let mut held = event_spans(events, 0);
383 held.extend(self.pins.values().map(crate::blocks::Pin::span));
385 for date in dates {
386 let mut taken = busy.clone();
387 taken.extend(self.kept_held(date, &zone, today, &held));
388 if date == today {
389 taken.push((i64::MIN, (now.timestamp().as_second() + 299) / 300 * 300));
391 }
392 let usual = stretches(&self.windows, date, &zone, self.anything);
393 let extra = self.extra_on(date, &zone, &usual);
395 if !extra.is_empty() {
396 self.light_only = Room::of(&less(&extra, &taken)).total();
397 }
398 let room = Room::of(&less(&[usual, extra].concat(), &taken));
399 self.days.insert(date, room);
400 }
401 self
402 }
403
404 pub fn room_on(&self, date: Date) -> &Room {
406 self.days.get(&date).unwrap_or(&self.week[date.weekday().to_monday_zero_offset() as usize])
407 }
408
409 pub fn usable(&self, task: &Task) -> Area {
414 if self.anything {
415 return Area::ALL;
416 }
417 let area = self.areas.of(task);
418 if task.office_hours && area.admin && !area.work {
419 return Area::MIXED;
420 }
421 Area { work: in_view(area, Time::Work, self.hours), admin: in_view(area, Time::Admin, self.hours), leisure: in_view(area, Time::Leisure, self.hours) }
422 }
423
424 pub fn rest_days(&self) -> [bool; 7] {
426 std::array::from_fn(|d| self.week[d].total() == 0)
427 }
428}
429
430#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
432pub struct Planned {
433 pub uid: String,
434 pub column: Column,
435 pub waits_for: Vec<String>,
437 pub not_before: Option<Date>,
439 pub unblocks: usize,
441 pub latest_start: Option<Date>,
443 pub start: Option<Date>,
445 pub finish: Option<Date>,
446 pub tight: bool,
448 pub in_loop: bool,
450 pub open_steps: usize,
452 pub depth: usize,
454 pub left: u32,
456 pub laid: u32,
458 pub level: &'static str,
460 pub on_start: u32,
462 pub optional: bool,
465}
466
467pub fn is_optional(task: &Task) -> bool {
472 (crate::capacity::level_of(task) == crate::demands::Level::Rest && task.due.is_empty()) || crate::words::named_in(&task.categories, &crate::words::current().tasks.optional)
473}
474
475#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
477pub struct Plan {
478 pub items: BTreeMap<String, Planned>,
480 pub order: Vec<String>,
482 pub ready: Vec<String>,
484 pub next: Option<String>,
487 pub streams: BTreeMap<String, usize>,
491 pub loops: Vec<Vec<String>>,
493 pub days: BTreeMap<Date, Vec<(String, u32)>>,
495 pub slack: BTreeMap<Date, u32>,
497 pub pins: BTreeMap<String, crate::blocks::Pin>,
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
505pub struct Cushion {
506 pub date: Date,
507 pub need: u32,
509 pub room: u32,
511}
512
513pub fn cushion(tasks: &[Task], plan: &Plan, settings: &Settings, today: Date, date: Date) -> Cushion {
516 let open = |uid: &str| plan.items.get(uid).filter(|p| p.column != Column::Done && !p.optional);
517 let mut wanted: Vec<String> = tasks.iter().filter(|t| t.due_date().is_some_and(|d| d <= date) && open(&t.uid).is_some()).map(|t| t.uid.clone()).collect();
518 let mut seen: BTreeSet<String> = BTreeSet::new();
519 let mut need = 0u32;
520 let mut kinds = Area::default();
522 while let Some(uid) = wanted.pop() {
523 if !seen.insert(uid.clone()) {
524 continue;
525 }
526 let Some(planned) = open(&uid) else { continue };
527 let held = plan.pins.get(&uid).filter(|pin| pin.date <= date).map_or(0, |pin| pin.minutes().saturating_add(pin.before).saturating_add(pin.after));
529 need = need.saturating_add(planned.laid.saturating_sub(held));
530 if let Some(task) = tasks.iter().find(|t| t.uid == uid) {
531 kinds = kinds.with(settings.usable(task));
532 }
533 wanted.extend(planned.waits_for.iter().cloned());
534 wanted.extend(tasks.iter().filter(|t| t.parent() == Some(uid.as_str())).map(|t| t.uid.clone()));
535 }
536 let mut room = 0u32;
537 let mut day = today;
538 while day <= date {
539 let usual = if settings.closed.contains(&day) { 0 } else { settings.room_on(day).for_kinds(kinds) };
540 room = room.saturating_add(if day == today { usual * settings.today_percent / 100 } else { usual });
541 let Ok(next) = day.tomorrow() else { break };
542 day = next;
543 }
544 Cushion { date, need, room }
545}
546
547fn add_days(date: Date, days: i64) -> Date {
550 Span::new().try_days(days).ok().and_then(|span| date.checked_add(span).ok()).unwrap_or(date)
551}
552
553fn gap_days(minutes: i64) -> i64 {
555 minutes / 1440
556}
557
558fn day_number(date: Date) -> i64 {
560 date.since(Date::constant(1970, 1, 1)).map_or(0, |s| i64::from(s.get_days()))
561}
562
563struct Graph {
565 after: Vec<Vec<(usize, i64)>>,
567 parent: Vec<Option<usize>>,
569 children: Vec<Vec<usize>>,
570}
571
572impl Graph {
573 fn new(tasks: &[&Task], index: &BTreeMap<&str, usize>) -> Graph {
574 let n = tasks.len();
575 let mut graph = Graph { after: vec![Vec::new(); n], parent: vec![None; n], children: vec![Vec::new(); n] };
576 let edge = |from: usize, to: usize, gap: i64, graph: &mut Graph| {
577 if from != to && !graph.after[from].iter().any(|(t, _)| *t == to) {
578 graph.after[from].push((to, gap));
579 }
580 };
581 for (i, task) in tasks.iter().enumerate() {
582 for relation in &task.relations {
583 let Some(&other) = index.get(relation.uid.as_str()) else { continue };
584 match relation.kind.as_str() {
585 "DEPENDS-ON" => edge(other, i, relation.gap, &mut graph),
587 "FINISHTOSTART" | "NEXT" => edge(i, other, relation.gap, &mut graph),
589 "PARENT" if graph.parent[i].is_none() && other != i => graph.parent[i] = Some(other),
590 "CHILD" if graph.parent[other].is_none() && other != i => graph.parent[other] = Some(i),
591 _ => {}
592 }
593 }
594 }
595 for i in 0..n {
597 let mut seen = BTreeSet::from([i]);
598 let mut at = graph.parent[i];
599 while let Some(p) = at {
600 if !seen.insert(p) {
601 graph.parent[i] = None;
602 break;
603 }
604 at = graph.parent[p];
605 }
606 }
607 for i in 0..n {
608 if let Some(p) = graph.parent[i] {
609 graph.children[p].push(i);
610 }
611 }
612 graph
613 }
614
615 fn ancestors(&self, i: usize) -> Vec<usize> {
616 let mut out = Vec::new();
617 let mut at = self.parent[i];
618 while let Some(p) = at {
619 out.push(p);
620 at = self.parent[p];
621 }
622 out
623 }
624
625 fn predecessors(&self, i: usize) -> Vec<(usize, i64)> {
627 let mut out = Vec::new();
628 for holder in std::iter::once(i).chain(self.ancestors(i)) {
629 for (from, list) in self.after.iter().enumerate() {
630 for &(to, gap) in list {
631 if to == holder && !out.iter().any(|(f, _)| *f == from) {
632 out.push((from, gap));
633 }
634 }
635 }
636 }
637 out
638 }
639}
640
641fn loops(n: usize, edges: &[Vec<usize>]) -> Vec<Vec<usize>> {
643 struct State<'a> {
644 edges: &'a [Vec<usize>],
645 index: Vec<Option<usize>>,
646 low: Vec<usize>,
647 on_stack: Vec<bool>,
648 stack: Vec<usize>,
649 next: usize,
650 found: Vec<Vec<usize>>,
651 }
652 fn visit(v: usize, s: &mut State) {
653 s.index[v] = Some(s.next);
654 s.low[v] = s.next;
655 s.next += 1;
656 s.stack.push(v);
657 s.on_stack[v] = true;
658 for &w in &s.edges[v] {
659 match s.index[w] {
660 None => {
661 visit(w, s);
662 s.low[v] = s.low[v].min(s.low[w]);
663 }
664 Some(index) if s.on_stack[w] => s.low[v] = s.low[v].min(index),
665 Some(_) => {}
666 }
667 }
668 if Some(s.low[v]) == s.index[v] {
669 let mut component = Vec::new();
670 while let Some(w) = s.stack.pop() {
671 s.on_stack[w] = false;
672 component.push(w);
673 if w == v {
674 break;
675 }
676 }
677 if component.len() > 1 {
678 component.sort_unstable();
679 s.found.push(component);
680 }
681 }
682 }
683 let mut state = State { edges, index: vec![None; n], low: vec![0; n], on_stack: vec![false; n], stack: Vec::new(), next: 0, found: Vec::new() };
684 for v in 0..n {
685 if state.index[v].is_none() {
686 visit(v, &mut state);
687 }
688 }
689 state.found
690}
691
692#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
694struct Rank {
695 set_aside: bool,
696 waiting: bool,
697 too_heavy: bool,
699 not_started: bool,
700 latest_start: i64,
701 priority: u8,
702 unblocks: Reverse<usize>,
703 left: u32,
704 created: i64,
705 title: String,
706 index: usize,
707}
708
709pub fn plan(tasks: &[Task], today: Date, settings: &Settings, spent: &BTreeMap<String, u32>, set_aside: &BTreeSet<String>) -> Plan {
712 let mut index: BTreeMap<&str, usize> = BTreeMap::new();
714 let mut unique: Vec<&Task> = Vec::new();
715 for task in tasks {
716 if !task.uid.is_empty() && !index.contains_key(task.uid.as_str()) {
717 index.insert(&task.uid, unique.len());
718 unique.push(task);
719 }
720 }
721 let tasks = unique;
722 let n = tasks.len();
723 let graph = Graph::new(&tasks, &index);
724 let open: Vec<bool> = tasks.iter().map(|t| t.status.is_open()).collect();
725 let open_steps: Vec<usize> = (0..n).map(|i| graph.children[i].iter().filter(|&&c| open[c]).count()).collect();
726 let optional: Vec<bool> = (0..n).map(|i| is_optional(tasks[i]) || graph.ancestors(i).iter().any(|&a| is_optional(tasks[a]))).collect();
727 let preds: Vec<Vec<(usize, i64)>> = (0..n).map(|i| graph.predecessors(i)).collect();
728
729 let mut order_edges: Vec<Vec<usize>> = vec![Vec::new(); n];
731 for i in (0..n).filter(|&i| open[i]) {
732 for &(p, _) in &preds[i] {
733 if open[p] && !order_edges[p].contains(&i) {
734 order_edges[p].push(i);
735 }
736 }
737 if let Some(p) = graph.parent[i].filter(|&p| open[p])
738 && !order_edges[i].contains(&p)
739 {
740 order_edges[i].push(p);
741 }
742 }
743 let found_loops = loops(n, &order_edges);
744 let loop_of: BTreeMap<usize, usize> = found_loops.iter().enumerate().flat_map(|(k, l)| l.iter().map(move |&i| (i, k))).collect();
745 for (from, list) in order_edges.iter_mut().enumerate() {
747 list.retain(|to| loop_of.get(&from).is_none_or(|k| loop_of.get(to) != Some(k)));
748 }
749
750 let minutes_left = |i: usize, corrected: bool| -> u32 {
753 if open_steps[i] > 0 {
754 return 0;
755 }
756 if !graph.children[i].is_empty() && tasks[i].estimate == 0 {
758 return 5;
759 }
760 let own = if tasks[i].estimate > 0 { tasks[i].estimate } else { settings.default_estimate };
761 let estimate = if corrected { settings.capacity.corrected.get(&tasks[i].uid).copied().unwrap_or(own) } else { own };
762 estimate.saturating_sub(spent.get(&tasks[i].uid).copied().unwrap_or(0)).max(5) + tasks[i].margins.before + tasks[i].margins.after
764 };
765 let left: Vec<u32> = (0..n).map(|i| minutes_left(i, false)).collect();
766 let laid: Vec<u32> = (0..n).map(|i| minutes_left(i, true)).collect();
767 let not_before: Vec<Option<Date>> = (0..n)
768 .map(|i| {
769 let mut day = tasks[i].start_date().filter(|d| *d > today);
770 for &(p, gap) in &preds[i] {
771 let done_on = tasks[p].completed.and_then(|t| jiff::Timestamp::from_second(t).ok()).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).date());
772 if tasks[p].status == Status::Completed
773 && let Some(done) = done_on
774 {
775 let free = add_days(done, gap_days(gap));
776 if free > today && day.is_none_or(|d| free > d) {
777 day = Some(free);
778 }
779 }
780 }
781 day
782 })
783 .collect();
784
785 let topological = kahn(n, &order_edges, &open, |i| (tasks[i].created, tasks[i].title.clone(), i));
787 let usable: Vec<Area> = tasks.iter().map(|t| settings.usable(t)).collect();
789 let weekly: Vec<u32> = usable.iter().map(|&kinds| settings.week.iter().map(|r| r.for_kinds(kinds)).sum()).collect();
790 let mut latest: Vec<Option<Date>> = vec![None; n];
791 for &i in topological.iter().rev() {
792 let mut finish = tasks[i].due_date();
793 for &s in &order_edges[i] {
794 let gap = preds[s].iter().find(|(p, _)| *p == i).map_or(0, |(_, g)| gap_days(*g));
795 if let Some(start) = latest[s] {
796 let bound = add_days(start, -gap);
797 finish = Some(finish.map_or(bound, |f| f.min(bound)));
798 }
799 }
800 let days = if weekly[i] == 0 { 0 } else { i64::from(laid[i]) * 7 / i64::from(weekly[i]) };
803 latest[i] = finish.map(|f| add_days(f, -days));
804 }
805
806 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); n];
808 for i in (0..n).filter(|&i| open[i]) {
809 for &(p, _) in &preds[i] {
810 if open[p] {
811 dependents[p].push(i);
812 }
813 }
814 }
815 let unblocks: Vec<usize> = (0..n)
816 .map(|i| {
817 let mut seen = BTreeSet::new();
818 let mut stack = dependents[i].clone();
819 while let Some(j) = stack.pop() {
820 if j != i && seen.insert(j) {
821 stack.extend(dependents[j].iter().copied());
822 }
823 }
824 seen.len()
825 })
826 .collect();
827
828 let waits_for: Vec<Vec<usize>> = (0..n).map(|i| preds[i].iter().map(|&(p, _)| p).filter(|&p| open[p]).collect()).collect();
829 let capacity = &settings.capacity;
831 let rates: Vec<crate::capacity::Rates> = tasks.iter().map(|t| capacity.rates_of(t)).collect();
832 let heavy: Vec<bool> = rates.iter().map(|r| r.level() == crate::demands::Level::Heavy).collect();
833 let light: Vec<bool> = rates.iter().map(|r| matches!(r.level(), crate::demands::Level::Light | crate::demands::Level::Rest)).collect();
835 let column = |i: usize| match tasks[i].status {
836 Status::Completed | Status::Cancelled => Column::Done,
837 Status::InProcess => Column::Doing,
838 Status::NeedsAction if !waits_for[i].is_empty() || not_before[i].is_some() => Column::Waiting,
839 Status::NeedsAction => Column::Ready,
840 };
841 let rank = |i: usize| Rank {
842 set_aside: set_aside.contains(&tasks[i].uid),
843 waiting: column(i) == Column::Waiting,
844 too_heavy: heavy[i] && settings.heavy_today == 0,
845 not_started: tasks[i].status != Status::InProcess,
846 latest_start: latest[i].map_or(i64::MAX, day_number),
847 unblocks: Reverse(unblocks[i]),
848 priority: if tasks[i].priority == 0 { 5 } else { tasks[i].priority },
849 left: left[i],
850 created: tasks[i].created,
851 title: tasks[i].title.to_lowercase(),
852 index: i,
853 };
854
855 let free_now = |i: usize| waits_for[i].is_empty() && not_before[i].is_none();
858 let mut ready: Vec<usize> = (0..n).filter(|&i| open[i] && open_steps[i] == 0 && free_now(i) && (column(i) == Column::Doing || (column(i) == Column::Ready && !optional[i]))).collect();
859 ready.sort_by_key(|&i| rank(i));
860 let mut root: Vec<usize> = (0..n).collect();
862 fn find(root: &mut [usize], mut i: usize) -> usize {
863 while root[i] != i {
864 root[i] = root[root[i]];
865 i = root[i];
866 }
867 i
868 }
869 for i in 0..n {
870 let tied: Vec<usize> = preds[i].iter().map(|&(p, _)| p).chain(graph.parent[i]).collect();
871 for j in tied {
872 let (a, b) = (find(&mut root, i), find(&mut root, j));
873 root[a.max(b)] = a.min(b);
874 }
875 }
876 let stream: Vec<usize> = (0..n).map(|i| find(&mut root, i)).collect();
877 let aside_streams: BTreeSet<usize> = (0..n).filter(|&i| set_aside.contains(&tasks[i].uid)).map(|i| stream[i]).collect();
878 let not_aside = |i: &&usize| !set_aside.contains(&tasks[**i].uid);
879 let next: Option<usize> = ready.iter().filter(not_aside).find(|&&i| !aside_streams.contains(&stream[i])).or_else(|| ready.iter().find(not_aside)).copied();
881
882 let order = kahn(n, &order_edges, &open, rank);
884 let office_days: Vec<[bool; 7]> = tasks
886 .iter()
887 .map(|t| {
888 let own = crate::window::parse_ranges(&t.office_times);
889 if own.is_empty() { settings.office_days } else { crate::window::days_open(&own) }
890 })
891 .collect();
892 let pins: Vec<Option<&crate::blocks::Pin>> = tasks.iter().map(|t| settings.pins.get(&t.uid).filter(|_| t.status.is_open())).collect();
894 let pinned: Vec<bool> = tasks.iter().enumerate().map(|(i, t)| pins[i].is_none() && t.at_on(today, &TimeZone::system()).is_some()).collect();
896 let room_for_tasks = |day: Date| settings.room_on(day).total() * if day == today { settings.today_percent } else { 100 } / 100;
898 let gain_kept = |day: Date| if settings.room_on(day).total() >= 120 { capacity.gain_slot } else { 0 };
900 let spread_on = |day: Date| -> Option<(crate::capacity::Spread, f32)> {
902 let spread = capacity.spread?;
903 Some(if day == today { (crate::capacity::Spread { day: if capacity.today_sigma > 0.0 { capacity.today_sigma } else { spread.day }, ..spread }, capacity.today_effect) } else { (spread, 0.0) })
904 };
905 let layout = |even: Option<&Load>| -> Layout {
908 let mut used: BTreeMap<Date, Vec<u32>> = BTreeMap::new();
910 let mut heavy_used: BTreeMap<Date, u32> = BTreeMap::new();
911 let mut light_used: u32 = 0;
913 let mut loaded: BTreeMap<Date, Load> = BTreeMap::new();
915 let mut sums: BTreeMap<Date, (f32, f32)> = BTreeMap::new();
917 let mut out = Layout { start: vec![None; n], finish: vec![None; n], on_start: vec![0; n], days: BTreeMap::new(), left: BTreeMap::new() };
918 for (i, pin) in pins.iter().enumerate() {
922 let Some(pin) = pin else { continue };
923 let day = pin.date.max(today);
924 let held = pin.minutes().saturating_add(pin.before).saturating_add(pin.after).max(SHORTEST);
925 let room = settings.room_on(day);
926 used.entry(day).or_insert_with(|| vec![0; room.0.len()]);
927 if heavy[i] {
928 *heavy_used.entry(day).or_insert(0) += 1;
929 }
930 crate::capacity::add(loaded.entry(day).or_insert([0.0; 5]), &rates[i].load(held));
931 let sum = sums.entry(day).or_insert((0.0, 0.0));
932 (sum.0, sum.1) = (sum.0 + held as f32, sum.1 + (held as f32).powi(2));
933 out.days.entry(day).or_default().push((i, held));
934 (out.start[i], out.finish[i], out.on_start[i]) = (Some(day), Some(day), held);
935 }
936 for &i in &order {
937 let mut earliest = not_before[i].unwrap_or(today).max(today);
938 for &(p, gap) in &preds[i] {
939 if let Some(end) = out.finish[p] {
940 earliest = earliest.max(add_days(end, gap_days(gap)));
941 }
942 }
943 let mut rest = None;
946 if let Some(pin) = pins[i] {
947 let day = pin.date.max(today);
948 if open_steps[i] > 0 {
949 let steps = || graph.children[i].iter().filter(|&&c| open[c]);
950 out.start[i] = steps().filter_map(|&c| out.start[c]).chain([day]).min();
951 out.finish[i] = steps().filter_map(|&c| out.finish[c]).chain([day]).max();
952 continue;
953 }
954 let left = laid[i].max(SHORTEST).saturating_sub(out.on_start[i]);
955 if left < SHORTEST || (optional[i] && tasks[i].status != Status::InProcess) {
956 continue;
957 }
958 earliest = earliest.max(add_days(day, 1));
959 rest = Some(left);
960 }
961 if optional[i] && tasks[i].status != Status::InProcess {
962 continue;
963 }
964 if open_steps[i] > 0 {
965 let steps = || graph.children[i].iter().filter(|&&c| open[c]);
967 out.start[i] = steps().filter_map(|&c| out.start[c]).min().or(Some(earliest));
968 out.finish[i] = steps().filter_map(|&c| out.finish[c]).max().or(Some(earliest));
969 continue;
970 }
971 let minutes_all = rest.unwrap_or(laid[i].max(SHORTEST));
972 if pinned[i] {
974 let room = settings.room_on(today);
975 let taken = used.entry(today).or_insert_with(|| vec![0; room.0.len()]);
976 let mut owed = minutes_all + settings.pause;
977 for k in (0..room.0.len()).filter(|&k| room.0[k].0.meets(usable[i])) {
978 let take = room.0[k].1.saturating_sub(taken[k]).min(owed);
979 taken[k] += take;
980 owed -= take;
981 }
982 if heavy[i] {
983 *heavy_used.entry(today).or_insert(0) += 1;
984 }
985 crate::capacity::add(loaded.entry(today).or_insert([0.0; 5]), &rates[i].load(minutes_all));
986 let sum = sums.entry(today).or_insert((0.0, 0.0));
987 (sum.0, sum.1) = (sum.0 + minutes_all as f32, sum.1 + (minutes_all as f32).powi(2));
988 out.days.entry(today).or_default().push((i, minutes_all));
989 (out.start[i], out.finish[i], out.on_start[i]) = (Some(today), Some(today), minutes_all);
990 continue;
991 }
992 let biggest = (0..7).filter(|&d| !tasks[i].office_hours || office_days[i][d]).map(|d| settings.week[d].for_kinds(usable[i])).max().unwrap_or(0);
994 if biggest == 0 {
996 (out.start[i], out.finish[i]) = (out.start[i].or(Some(earliest)), Some(earliest));
997 continue;
998 }
999 let (mut day, mut minutes) = (earliest, minutes_all);
1000 let margins = !tasks[i].margins.is_empty();
1001 let whole = (minutes <= WHOLE || margins) && minutes <= biggest;
1004 let alone = margins && minutes > biggest;
1006 let per_hour = rates[i].per_hour();
1007 for _ in 0..3 * 366 {
1008 let weekday = day.weekday().to_monday_zero_offset() as usize;
1009 let lighter = u32::from(capacity.lightened.contains(&day));
1011 let heavy_limit = if day == today { settings.heavy_today } else { settings.heavy_per_day }.saturating_sub(lighter);
1012 let heavy_full = heavy[i] && settings.heavy_per_day > 0 && heavy_used.get(&day).copied().unwrap_or(0) >= heavy_limit;
1013 if !(heavy_full || settings.closed.contains(&day) || (tasks[i].office_hours && !office_days[i][weekday])) {
1014 let room = settings.room_on(day);
1015 let percent = if day == today { settings.today_percent } else { 100 };
1016 let taken = used.entry(day).or_insert_with(|| vec![0; room.0.len()]);
1017 let mut kinds: Vec<usize> = (0..room.0.len()).filter(|&k| room.0[k].0.meets(usable[i])).collect();
1019 kinds.sort_by_key(|&k| room.0[k].0.count());
1020 let free_in = |k: usize, taken: &[u32]| (room.0[k].1 * percent / 100).saturating_sub(taken[k]);
1021 let first = kinds.iter().all(|&k| taken[k] == 0);
1024 let pause = if first { 0 } else { settings.pause };
1025 let open_here: u32 = kinds.iter().map(|&k| free_in(k, taken)).sum::<u32>().saturating_sub(pause);
1026 let kept_light = if day == today { settings.light_only.saturating_sub(light_used) } else { 0 };
1028 let open_here = if light[i] { open_here } else { open_here.saturating_sub(kept_light) };
1029 let mut free = open_here.saturating_sub(gain_kept(day));
1033 if let Some((spread, effect)) = spread_on(day) {
1034 let total = room_for_tasks(day);
1035 let cap = crate::capacity::SLACK_SHARE * total as f32;
1036 let day_left = total.saturating_sub(taken.iter().sum::<u32>()).saturating_sub(pause).saturating_sub(gain_kept(day)) as f32;
1037 let (sum, squares) = sums.get(&day).copied().unwrap_or((0.0, 0.0));
1038 let fits = |p: u32| p as f32 + crate::capacity::slack_estimate(sum + p as f32, squares + (p as f32).powi(2), spread, effect).min(cap) <= day_left + 1e-3;
1039 let upper = free.min(minutes);
1040 free = if fits(upper) {
1041 upper
1042 } else if !fits(0) {
1043 0
1044 } else {
1045 let (mut lo, mut hi) = (0, upper);
1046 while hi - lo > 1 {
1047 let mid = (lo + hi) / 2;
1048 if fits(mid) { lo = mid } else { hi = mid }
1049 }
1050 lo
1051 };
1052 }
1053 let mut part = shaped(free, minutes, whole, alone, first);
1054 if part < minutes.min(SHORTEST) && !alone && next == Some(i) && day == today {
1058 part = shaped(open_here, minutes, whole, alone, first);
1059 }
1060 if part > 0
1062 && let Some(mut limit) = capacity.day_limit(day, percent as f32 / 100.0)
1063 {
1064 if let Some(even) = even {
1065 for s in 0..5 {
1066 limit[s] = limit[s].min(even[s]);
1067 }
1068 }
1069 let tasks_here = loaded.get(&day).copied().unwrap_or([0.0; 5]);
1070 let mut held = capacity.held.get(&day).copied().unwrap_or([0.0; 5]);
1071 crate::capacity::add(&mut held, &tasks_here);
1072 let fits_under = |m: u32, base: &Load, limit: &Load| {
1073 let load = rates[i].load(m);
1074 (0..5).all(|s| load[s] <= 0.0 || base[s] + load[s] <= limit[s] + 1e-3)
1075 };
1076 let fits = |m: u32, base: &Load| fits_under(m, base, &limit);
1077 if !fits(part, &held) {
1078 let ordinary = capacity.limit.map_or(limit, |l| l.map(|b| b * crate::capacity::FILL));
1081 let never = !fits_under(if whole || alone { minutes } else { SHORTEST }, &[0.0; 5], &ordinary);
1082 part = if never {
1083 if tasks_here[crate::capacity::TOTAL] <= 0.0 { part } else { 0 }
1084 } else if whole || alone {
1085 0
1086 } else {
1087 let most = (0..5).filter(|&s| per_hour[s] > 0.0).map(|s| ((limit[s] - held[s]).max(0.0) / per_hour[s] * 60.0) as u32).min().unwrap_or(part);
1089 let mut fitting = most.min(part);
1090 if fitting < minutes && minutes - fitting < SHORTEST {
1091 fitting = fitting.min(minutes.saturating_sub(SHORTEST));
1092 }
1093 if fitting >= SHORTEST && fits(fitting, &held) { fitting } else { 0 }
1094 };
1095 }
1096 }
1097 if part > 0 && (part >= minutes.min(SHORTEST) || alone) {
1098 let mut owed = part + settings.pause;
1099 for &k in &kinds {
1100 let take = free_in(k, taken).min(owed);
1101 taken[k] += take;
1102 owed -= take;
1103 }
1104 if heavy[i] {
1105 *heavy_used.entry(day).or_insert(0) += 1;
1106 }
1107 if light[i] && day == today {
1108 light_used += (part + settings.pause).min(settings.light_only.saturating_sub(light_used));
1109 }
1110 crate::capacity::add(loaded.entry(day).or_insert([0.0; 5]), &rates[i].load(part));
1111 let sum = sums.entry(day).or_insert((0.0, 0.0));
1112 (sum.0, sum.1) = (sum.0 + part as f32, sum.1 + (part as f32).powi(2));
1113 out.days.entry(day).or_default().push((i, part));
1114 minutes -= part;
1115 if out.start[i].is_none() {
1116 (out.start[i], out.on_start[i]) = (Some(day), part);
1117 }
1118 if minutes == 0 {
1119 break;
1120 }
1121 }
1122 }
1123 day = add_days(day, 1);
1124 }
1125 out.finish[i] = Some(day);
1126 out.start[i] = out.start[i].or(Some(day));
1127 }
1128 out.left = used.iter().map(|(&day, taken)| (day, room_for_tasks(day).saturating_sub(taken.iter().sum::<u32>()).saturating_sub(gain_kept(day)))).collect();
1130 out
1131 };
1132 let mut placed = layout(None);
1136 if capacity.even && capacity.limit.is_some() {
1137 let week: Vec<Date> = (0..7).map(|k| add_days(today, k)).filter(|d| !settings.closed.contains(d) && settings.room_on(*d).total() > 0).collect();
1138 let mut sum: Load = [0.0; 5];
1139 for date in &week {
1140 if let Some(held) = capacity.held.get(date) {
1141 crate::capacity::add(&mut sum, held);
1142 }
1143 for &(i, m) in placed.days.get(date).into_iter().flatten() {
1144 crate::capacity::add(&mut sum, &rates[i].load(m));
1145 }
1146 }
1147 if !week.is_empty() {
1148 let target: Load = sum.map(|v| if v > 0.0 { v / week.len() as f32 } else { f32::INFINITY });
1150 placed = layout(Some(&target));
1151 }
1152 }
1153 let mut slack: BTreeMap<Date, u32> = BTreeMap::new();
1157 for (&date, steps) in &placed.days {
1158 let Some((spread, effect)) = spread_on(date) else { break };
1159 let medians: Vec<u32> = steps.iter().map(|&(_, m)| m).collect();
1160 let uids: Vec<&str> = steps.iter().map(|&(i, _)| tasks[i].uid.as_str()).collect();
1161 let cap = (crate::capacity::SLACK_SHARE * room_for_tasks(date) as f32) as u32;
1162 let kept = crate::capacity::slack(&medians, spread, effect, crate::capacity::seed(date, &uids)).min(cap).min(placed.left.get(&date).copied().unwrap_or(0));
1163 if kept > 0 {
1164 slack.insert(date, kept);
1165 }
1166 }
1167 let Layout { start, finish, on_start, days: placed_days, .. } = placed;
1168
1169 let depth = |i: usize| graph.ancestors(i).len();
1170 let mut items = BTreeMap::new();
1171 for i in 0..n {
1172 let tight = open[i] && matches!((finish[i], tasks[i].due_date()), (Some(f), Some(d)) if f > d);
1173 items.insert(
1174 tasks[i].uid.clone(),
1175 Planned {
1176 uid: tasks[i].uid.clone(),
1177 column: column(i),
1178 waits_for: waits_for[i].iter().map(|&p| tasks[p].uid.clone()).collect(),
1179 not_before: not_before[i],
1180 unblocks: unblocks[i],
1181 latest_start: latest[i],
1182 start: start[i],
1183 finish: finish[i],
1184 tight,
1185 in_loop: loop_of.contains_key(&i),
1186 open_steps: open_steps[i],
1187 depth: depth(i),
1188 left: left[i],
1189 laid: laid[i],
1190 level: rates[i].level().id(),
1191 on_start: on_start[i],
1192 optional: optional[i],
1193 },
1194 );
1195 }
1196 Plan {
1197 items,
1198 order: order.iter().map(|&i| tasks[i].uid.clone()).collect(),
1199 ready: ready.iter().map(|&i| tasks[i].uid.clone()).collect(),
1200 next: next.map(|i| tasks[i].uid.clone()),
1201 loops: found_loops.iter().map(|l| l.iter().map(|&i| tasks[i].uid.clone()).collect()).collect(),
1202 streams: (0..n).map(|i| (tasks[i].uid.clone(), stream[i])).collect(),
1203 days: placed_days.into_iter().map(|(date, steps)| (date, steps.into_iter().map(|(i, m)| (tasks[i].uid.clone(), m)).collect())).collect(),
1204 slack,
1205 pins: pins.iter().enumerate().filter_map(|(i, pin)| pin.map(|pin| (tasks[i].uid.clone(), pin.clone()))).collect(),
1206 }
1207}
1208
1209struct Layout {
1212 start: Vec<Option<Date>>,
1213 finish: Vec<Option<Date>>,
1214 on_start: Vec<u32>,
1215 days: BTreeMap<Date, Vec<(usize, u32)>>,
1216 left: BTreeMap<Date, u32>,
1217}
1218
1219fn shaped(free: u32, minutes: u32, whole: bool, alone: bool, first: bool) -> u32 {
1224 if alone {
1225 return if first { minutes } else { 0 };
1226 }
1227 let part = free.min(minutes);
1228 if part < minutes && (whole || minutes - part < SHORTEST) {
1229 return if whole { 0 } else { part.min(minutes.saturating_sub(SHORTEST)) };
1230 }
1231 part
1232}
1233
1234type Load = crate::capacity::Load;
1236
1237fn kahn<K: Ord>(n: usize, edges: &[Vec<usize>], open: &[bool], key: impl Fn(usize) -> K) -> Vec<usize> {
1239 let mut incoming = vec![0usize; n];
1240 for (from, list) in edges.iter().enumerate() {
1241 if open[from] {
1242 for &to in list {
1243 incoming[to] += 1;
1244 }
1245 }
1246 }
1247 let mut free: BinaryHeap<Reverse<(K, usize)>> = (0..n).filter(|&i| open[i] && incoming[i] == 0).map(|i| Reverse((key(i), i))).collect();
1248 let mut out = Vec::with_capacity(n);
1249 while let Some(Reverse((_, i))) = free.pop() {
1250 out.push(i);
1251 for &to in &edges[i] {
1252 incoming[to] -= 1;
1253 if incoming[to] == 0 {
1254 free.push(Reverse((key(to), to)));
1255 }
1256 }
1257 }
1258 out
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::*;
1264
1265 #[test]
1266 fn offices_and_days_off() {
1267 let today: Date = "2026-10-03".parse().unwrap();
1269 let call = Task { uid: "call".into(), title: "Call the office".into(), estimate: 15, office_hours: true, ..Task::default() };
1270 let mut settings = Settings::flat([60; 7]);
1271 let planned = plan(&[call.clone()], today, &settings, &BTreeMap::new(), &BTreeSet::new());
1272 assert_eq!(planned.items["call"].start.map(|d| d.to_string()).as_deref(), Some("2026-10-05"), "on Monday, when offices open");
1273 settings.closed.insert("2026-10-05".parse().unwrap());
1275 let planned = plan(&[call], today, &settings, &BTreeMap::new(), &BTreeSet::new());
1276 assert_eq!(planned.items["call"].start.map(|d| d.to_string()).as_deref(), Some("2026-10-06"));
1277 }
1278
1279 #[test]
1280 fn a_step_started_too_soon_waits() {
1281 let mut started = waits(task("send", 10), "call");
1282 started.status = Status::InProcess;
1283 let tasks = vec![task("call", 10), started];
1284 let plan = plan(&tasks, today(), &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1285 assert_eq!(plan.items["send"].column, Column::Doing, "still shown as started");
1286 assert!(!plan.ready.contains(&"send".to_string()), "never proposed before what it waits for");
1287 assert!(plan.ready.contains(&"call".to_string()));
1288 }
1289
1290 #[test]
1291 fn an_office_with_its_own_hours() {
1292 let call = Task { uid: "call".into(), estimate: 15, office_hours: true, office_times: "sa 09:00-12:00".into(), ..Task::default() };
1294 let usual = Task { uid: "usual".into(), estimate: 15, office_hours: true, ..Task::default() };
1295 let settings = Settings::flat([60; 7]);
1296 let plan = plan(&[call, usual], day("2026-10-03"), &settings, &BTreeMap::new(), &BTreeSet::new());
1297 assert_eq!(plan.items["call"].start, Some(day("2026-10-03")));
1298 assert_eq!(plan.items["usual"].start, Some(day("2026-10-05")), "the usual offices open Monday");
1299 }
1300
1301 #[test]
1302 fn not_now_takes_its_stream_with_it() {
1303 let today: Date = "2026-10-03".parse().unwrap();
1304 let task = |uid: &str| Task { uid: uid.into(), title: uid.into(), estimate: 15, ..Task::default() };
1305 let call = Task { due: "2026-10-20".into(), ..task("call") };
1307 let arrives = Task { relations: vec![Relation { kind: "DEPENDS-ON".into(), uid: "call".into(), gap: 0 }], ..task("arrives") };
1308 let request = Task { due: "2026-10-30".into(), ..task("request") };
1309 let form = Task { relations: vec![Relation { kind: "PARENT".into(), uid: "request".into(), gap: 0 }], ..task("form") };
1310 let send = Task { relations: vec![Relation { kind: "PARENT".into(), uid: "request".into(), gap: 0 }, Relation { kind: "DEPENDS-ON".into(), uid: "arrives".into(), gap: 0 }], ..task("send") };
1311 let shelf = task("shelf");
1313 let tasks = vec![call, arrives, request, form, send, shelf];
1314 let none = BTreeSet::new();
1315 let planned = plan(&tasks, today, &Settings::default(), &BTreeMap::new(), &none);
1316 assert_eq!(planned.next.as_deref(), Some("call"));
1317 assert_eq!(planned.streams["call"], planned.streams["form"], "the form shares the call's stream through its bigger task");
1318 assert_ne!(planned.streams["call"], planned.streams["shelf"]);
1319 let aside: BTreeSet<String> = ["call".to_string()].into();
1321 assert_eq!(plan(&tasks, today, &Settings::default(), &BTreeMap::new(), &aside).next.as_deref(), Some("shelf"));
1322 let aside: BTreeSet<String> = ["call".to_string(), "shelf".to_string()].into();
1324 assert_eq!(plan(&tasks, today, &Settings::default(), &BTreeMap::new(), &aside).next.as_deref(), Some("form"));
1325 }
1326 use crate::tasks::Relation;
1327
1328 fn task(uid: &str, minutes: u32) -> Task {
1329 Task { uid: uid.into(), title: uid.into(), estimate: minutes, ..Task::default() }
1330 }
1331
1332 fn waits(mut t: Task, on: &str) -> Task {
1333 t.relations.push(Relation { kind: "DEPENDS-ON".into(), uid: on.into(), gap: 0 });
1334 t
1335 }
1336
1337 fn day(text: &str) -> Date {
1338 text.parse().unwrap()
1339 }
1340
1341 fn today() -> Date {
1343 day("2026-10-03")
1344 }
1345
1346 #[test]
1347 fn waits_in_order_and_says_loops() {
1348 let tasks = vec![waits(task("c", 15), "b"), waits(task("b", 15), "a"), task("a", 15), waits(task("x", 15), "y"), waits(task("y", 15), "x")];
1349 let plan = plan(&tasks, today(), &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1350 let position = |u: &str| plan.order.iter().position(|o| o == u).unwrap();
1351 assert!(position("a") < position("b") && position("b") < position("c"), "{:?}", plan.order);
1352 assert_eq!(plan.loops, vec![vec!["x".to_string(), "y".to_string()]]);
1353 assert_eq!(plan.items["c"].column, Column::Waiting);
1354 assert_eq!(plan.items["c"].waits_for, vec!["b"]);
1355 assert_eq!((plan.items["a"].column, plan.items["a"].unblocks), (Column::Ready, 2));
1356 assert!(plan.items["x"].in_loop && plan.order.len() == 5);
1357 }
1358
1359 #[test]
1360 fn the_next_step() {
1361 let mut deadline = task("deadline", 60);
1362 deadline.due = "2026-10-09".into();
1363 let mut started = task("started", 30);
1364 started.status = Status::InProcess;
1365 let tasks = vec![task("small", 5), deadline.clone(), waits(task("after", 5), "unlocking"), task("unlocking", 30)];
1366 let plan_of = |tasks: &[Task], aside: &[&str]| plan(tasks, today(), &Settings::default(), &BTreeMap::new(), &aside.iter().map(|s| s.to_string()).collect());
1367 assert_eq!(plan_of(&tasks, &[]).ready, vec!["deadline", "unlocking", "small"]);
1369 assert_eq!(plan_of(&tasks, &["deadline"]).next.as_deref(), Some("unlocking"));
1370 let mut with_started = tasks.clone();
1371 with_started.push(started);
1372 assert_eq!(plan_of(&with_started, &[]).next.as_deref(), Some("started"));
1373 }
1374
1375 #[test]
1376 fn days_with_room_after_what_comes_first() {
1377 let mut letter = task("letter", 15);
1379 letter.status = Status::Completed;
1380 letter.completed = Some(day("2026-10-01").to_zoned(jiff::tz::TimeZone::system()).unwrap().timestamp().as_second());
1381 let mut answer = task("answer", 15);
1382 answer.relations.push(Relation { kind: "DEPENDS-ON".into(), uid: "letter".into(), gap: 14 * 1440 });
1383 let mut late = task("late", 45);
1384 late.due = "2026-10-05".into();
1385 let tasks = vec![task("one", 45), waits(task("two", 30), "one"), letter, answer, late];
1386 let plan = plan(&tasks, today(), &Settings::flat([60, 60, 60, 60, 60, 0, 0]), &BTreeMap::new(), &BTreeSet::new());
1387 let days = |u: &str| (plan.items[u].start.unwrap().to_string(), plan.items[u].finish.unwrap().to_string());
1388 assert_eq!(days("late"), ("2026-10-05".into(), "2026-10-05".into()));
1390 assert!(!plan.items["late"].tight);
1391 assert_eq!(days("one"), ("2026-10-06".into(), "2026-10-06".into()));
1393 assert_eq!(days("two"), ("2026-10-07".into(), "2026-10-07".into()));
1394 assert_eq!(plan.items["answer"].not_before, Some(day("2026-10-15")));
1396 assert_eq!((plan.items["answer"].column, days("answer").0), (Column::Waiting, "2026-10-15".to_string()));
1397 }
1398
1399 #[test]
1400 fn optional_tasks_are_never_proposed() {
1401 let mut music = task("music", 30);
1402 music.categories = vec!["JOY".into()];
1403 let mut parked = task("parked", 15);
1404 parked.categories = vec!["someday".into()];
1405 let mut step = task("parked-step", 5);
1406 step.relations.push(Relation { kind: "PARENT".into(), uid: "parked".into(), gap: 0 });
1407 let tasks = vec![music, parked, step, task("letter", 15)];
1408 let plan = plan(&tasks, today(), &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1409 assert_eq!(plan.ready, vec!["letter"]);
1410 assert!(plan.items["parked-step"].optional && plan.items["music"].optional && !plan.items["letter"].optional);
1411 assert_eq!((plan.items["music"].start, plan.items["parked"].finish), (None, None));
1412 }
1413
1414 #[test]
1415 fn steps_of_a_bigger_task() {
1416 let mut step_one = task("step-one", 20);
1417 step_one.relations.push(Relation { kind: "PARENT".into(), uid: "big".into(), gap: 0 });
1418 let mut step_two = waits(task("step-two", 20), "step-one");
1419 step_two.relations.push(Relation { kind: "PARENT".into(), uid: "big".into(), gap: 0 });
1420 let big = waits(task("big", 0), "papers");
1421 let tasks = vec![big, step_one, step_two, task("papers", 10), waits(task("then", 10), "big")];
1422 let plan = plan(&tasks, today(), &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1423 assert_eq!(plan.items["step-one"].waits_for, vec!["papers"]);
1425 assert_eq!((plan.items["big"].open_steps, plan.items["step-one"].depth), (2, 1));
1426 assert_eq!(plan.ready, vec!["papers"]);
1427 let position = |u: &str| plan.order.iter().position(|o| o == u).unwrap();
1428 assert!(position("papers") < position("step-one") && position("step-two") < position("big") && position("big") < position("then"), "{:?}", plan.order);
1429 assert_eq!(plan.items["big"].start, plan.items["step-one"].start);
1430 }
1431 #[test]
1432 fn time_budget_until_a_date() {
1433 let today = Date::constant(2026, 10, 5); let task = |uid: &str, estimate: u32, due: &str| Task { uid: uid.into(), title: uid.into(), estimate, due: due.into(), ..Task::default() };
1435 let mut form = task("form", 60, "2026-10-07");
1437 form.relations = vec![Relation { kind: "DEPENDS-ON".into(), uid: "scan".into(), gap: 0 }];
1438 let scan = task("scan", 30, "");
1439 let letter = task("letter", 90, "2026-10-06");
1440 let mut step_a = task("a", 20, "");
1441 step_a.relations = vec![Relation { kind: "PARENT".into(), uid: "letter".into(), gap: 0 }];
1442 let mut step_b = task("b", 20, "");
1443 step_b.relations = vec![Relation { kind: "PARENT".into(), uid: "letter".into(), gap: 0 }];
1444 let later = task("later", 120, "2026-10-20");
1446 let mut joy = task("joy", 60, "2026-10-06");
1447 joy.categories = vec!["joy".into()];
1448 let tasks = vec![form, scan, letter, step_a, step_b, later, joy];
1449 let mut settings = Settings::flat([60, 60, 60, 60, 60, 0, 0]);
1450 settings.today_percent = 50;
1451 let plan = plan(&tasks, today, &settings, &BTreeMap::new(), &BTreeSet::new());
1452 let budget = cushion(&tasks, &plan, &settings, today, Date::constant(2026, 10, 7));
1453 assert_eq!(budget.need, 60 + 30 + 20 + 20);
1454 assert_eq!(budget.room, 30 + 60 + 60);
1456 settings.closed.insert(Date::constant(2026, 10, 6));
1457 assert_eq!(cushion(&tasks, &plan, &settings, today, Date::constant(2026, 10, 7)).room, 30 + 60);
1458 }
1459
1460 fn rated(uid: &str, minutes: u32, cognitive: u8) -> Task {
1461 Task { demands: crate::demands::Demands { cognitive: Some(cognitive), ..crate::demands::Demands::default() }, ..task(uid, minutes) }
1462 }
1463
1464 fn with_budget(limit: f32) -> Settings {
1465 let mut settings = Settings::flat([480; 7]);
1466 settings.capacity.limit = Some([limit; 5]);
1467 settings
1468 }
1469
1470 #[test]
1471 fn each_day_filled_to_its_budget() {
1472 let today = day("2026-10-05");
1474 let tasks = vec![rated("a", 60, 8), rated("b", 60, 8), rated("c", 60, 8)];
1475 let made = plan(&tasks, today, &with_budget(10.0), &BTreeMap::new(), &BTreeSet::new());
1476 let starts: BTreeSet<Option<Date>> = ["a", "b", "c"].iter().map(|u| made.items[*u].start).collect();
1477 assert_eq!(starts.len(), 3, "one a day: {starts:?}");
1478 let free = plan(&tasks, today, &Settings::flat([480; 7]), &BTreeMap::new(), &BTreeSet::new());
1480 assert!(["a", "b", "c"].iter().filter(|u| free.items[**u].start == Some(today)).count() >= 2);
1481 let long = rated("long", 300, 2);
1483 let made = plan(std::slice::from_ref(&long), today, &with_budget(4.0), &BTreeMap::new(), &BTreeSet::new());
1484 assert!(made.items["long"].finish > Some(today), "2 × 5 h = 10 > 3.4: spread: {:?}", made.items["long"]);
1485 let mut haze = with_budget(10.0);
1487 haze.today_percent = 60;
1488 let made = plan(&[rated("a", 60, 8)], today, &haze, &BTreeMap::new(), &BTreeSet::new());
1489 assert_eq!(made.items["a"].start, Some(day("2026-10-06")));
1490 }
1491
1492 #[test]
1493 fn heavier_than_any_day_gets_a_day_of_its_own() {
1494 let today = day("2026-10-05");
1495 let small = Task { due: "2026-10-05".into(), ..task("small", 15) };
1497 let huge = Task { due: "2026-10-30".into(), ..rated("huge", 60, 8) };
1498 let made = plan(&[small, huge], today, &with_budget(4.0), &BTreeMap::new(), &BTreeSet::new());
1499 assert_eq!(made.items["small"].start, Some(today));
1500 assert_eq!((made.items["huge"].start, made.items["huge"].finish), (Some(day("2026-10-06")), Some(day("2026-10-06"))), "alone, whole, the next day");
1501 }
1502
1503 #[test]
1504 fn even_days_spread_the_week() {
1505 let today = day("2026-10-05");
1506 let tasks: Vec<Task> = (0..7).map(|k| task(&format!("t{k}"), 60)).collect();
1507 let mut settings = with_budget(100.0);
1508 let all_at_once = plan(&tasks, today, &settings, &BTreeMap::new(), &BTreeSet::new());
1509 assert!(tasks.iter().all(|t| all_at_once.items[&t.uid].start == Some(today)), "varied: the first day holds them all");
1510 settings.capacity.even = true;
1511 let even = plan(&tasks, today, &settings, &BTreeMap::new(), &BTreeSet::new());
1512 let days: BTreeSet<Option<Date>> = tasks.iter().map(|t| even.items[&t.uid].start).collect();
1513 assert_eq!(days.len(), 7, "one a day: {days:?}");
1514 assert!(!Settings::default().capacity.even);
1516 }
1517
1518 #[test]
1519 fn a_heavy_event_lightens_the_days_around_it() {
1520 let today = day("2026-10-05");
1522 let heavy = |uid: &str| Task { energy: "heavy".into(), ..task(uid, 20) };
1523 let tasks = vec![heavy("h1"), heavy("h2"), heavy("h3"), heavy("h4")];
1524 let mut settings = Settings::default();
1525 settings.capacity.lightened = BTreeSet::from([day("2026-10-06")]);
1526 let made = plan(&tasks, today, &settings, &BTreeMap::new(), &BTreeSet::new());
1527 let on = |d: &str| tasks.iter().filter(|t| made.items[&t.uid].start == Some(day(d))).count();
1528 assert_eq!((on("2026-10-05"), on("2026-10-06"), on("2026-10-07")), (2, 1, 1));
1529 let mut budget = with_budget(10.0);
1531 budget.capacity.lightened = BTreeSet::from([today]);
1532 let made = plan(&[rated("a", 60, 8)], today, &budget, &BTreeMap::new(), &BTreeSet::new());
1533 assert_eq!(made.items["a"].start, Some(day("2026-10-06")), "8 > 8.5 × 0.75");
1534 }
1535
1536 #[test]
1537 fn margins_keep_a_task_whole_across_days() {
1538 let today = day("2026-10-05");
1540 let errand = Task { margins: crate::demands::Margins { before: 15, after: 15 }, ..task("errand", 40) };
1541 let made = plan(std::slice::from_ref(&errand), today, &Settings::flat([60; 7]), &BTreeMap::new(), &BTreeSet::new());
1542 assert_eq!(made.items["errand"].start, made.items["errand"].finish, "a day of its own, whole");
1543 assert_eq!(made.items["errand"].on_start, 70);
1544 let made = plan(&[task("plain", 70)], today, &Settings::flat([60; 7]), &BTreeMap::new(), &BTreeSet::new());
1546 assert!(made.items["plain"].finish > made.items["plain"].start);
1547 }
1548
1549 #[test]
1550 fn corrected_lengths_and_what_gives_back() {
1551 let today = day("2026-10-05");
1552 let mut settings = Settings::default();
1553 settings.capacity.corrected = BTreeMap::from([("a".to_string(), 45)]);
1554 let made = plan(&[task("a", 30)], today, &settings, &BTreeMap::new(), &BTreeSet::new());
1555 assert_eq!((made.items["a"].left, made.items["a"].laid, made.items["a"].on_start), (30, 45, 45), "shown as estimated, laid as corrected");
1556 let gives = Task { demands: crate::demands::Demands { cognitive: Some(2), gain: Some(7), ..crate::demands::Demands::default() }, ..task("walk", 30) };
1558 assert!(is_optional(&gives));
1559 assert!(!is_optional(&Task { due: "2026-10-09".into(), ..gives.clone() }));
1560 assert_eq!(plan(&[gives], today, &settings, &BTreeMap::new(), &BTreeSet::new()).items["walk"].level, "rest");
1561 }
1562
1563 #[test]
1564 fn free_time_is_the_final_placement_s() {
1565 let today = day("2026-10-05");
1567 let spread = crate::capacity::Spread::of(0.5);
1568 let mut settings = Settings::flat([480; 7]);
1569 settings.capacity.spread = Some(spread);
1570 let tasks = vec![task("a", 30), task("b", 30), task("c", 30)];
1571 let made = plan(&tasks, today, &settings, &BTreeMap::new(), &BTreeSet::new());
1572 let steps = &made.days[&today];
1573 let medians: Vec<u32> = steps.iter().map(|(_, m)| *m).collect();
1574 let uids: Vec<&str> = steps.iter().map(|(u, _)| u.as_str()).collect();
1575 assert_eq!(medians, vec![30, 30, 30]);
1576 assert_eq!(made.slack[&today], crate::capacity::slack(&medians, spread, 0.0, crate::capacity::seed(today, &uids)));
1577 settings.capacity.limit = Some([100.0; 5]);
1579 settings.capacity.even = true;
1580 let week: Vec<Task> = (0..7).map(|k| task(&format!("t{k}"), 60)).collect();
1581 let made = plan(&week, today, &settings, &BTreeMap::new(), &BTreeSet::new());
1582 assert!(made.days.len() > 1);
1583 for (date, steps) in &made.days {
1584 let medians: Vec<u32> = steps.iter().map(|(_, m)| *m).collect();
1585 let uids: Vec<&str> = steps.iter().map(|(u, _)| u.as_str()).collect();
1586 assert_eq!(made.slack.get(date).copied().unwrap_or(0), crate::capacity::slack(&medians, spread, 0.0, crate::capacity::seed(*date, &uids)), "{date}");
1587 }
1588 assert!(plan(&tasks, today, &Settings::flat([480; 7]), &BTreeMap::new(), &BTreeSet::new()).slack.is_empty());
1590 }
1591
1592 #[test]
1593 fn the_next_step_keeps_today_when_little_is_left() {
1594 let today = day("2026-10-05");
1596 let mut settings = Settings::flat([40, 480, 480, 480, 480, 480, 480]);
1597 settings.capacity.spread = Some(crate::capacity::Spread::of(0.707));
1598 let started = Task { status: Status::InProcess, ..task("long", 240) };
1599 let made = plan(&[started], today, &settings, &BTreeMap::new(), &BTreeSet::new());
1600 assert_eq!((made.next.as_deref(), made.items["long"].start), (Some("long"), Some(today)));
1601 assert!(made.items["long"].on_start >= SHORTEST);
1602 let errand = Task { status: Status::InProcess, margins: crate::demands::Margins { before: 15, after: 15 }, ..task("errand", 60) };
1604 let made = plan(&[errand], today, &settings, &BTreeMap::new(), &BTreeSet::new());
1605 assert_eq!((made.items["errand"].start, made.items["errand"].finish), (Some(day("2026-10-06")), Some(day("2026-10-06"))));
1606 }
1607
1608 #[test]
1609 fn heavy_days_and_rest() {
1610 let today = Date::constant(2026, 10, 5); let task = |uid: &str, energy: &str| Task { uid: uid.into(), title: uid.into(), estimate: 20, energy: energy.into(), ..Task::default() };
1612 let tasks = vec![task("h1", "heavy"), task("h2", "heavy"), task("h3", "heavy"), task("light", "light"), task("walk", "rest")];
1613 let clear = Settings::default();
1615 let made = plan(&tasks, today, &clear, &BTreeMap::new(), &BTreeSet::new());
1616 let starts: Vec<Option<Date>> = ["h1", "h2", "h3"].iter().map(|u| made.items[*u].start).collect();
1617 assert_eq!(starts.iter().filter(|d| **d == Some(today)).count(), 2, "{starts:?}");
1618 assert!(starts.contains(&Some(Date::constant(2026, 10, 6))));
1619 assert!(made.items["walk"].optional && made.next.as_deref() != Some("walk"));
1620 let fog = Settings { heavy_today: 0, today_percent: 30, ..Settings::default() };
1622 let made = plan(&tasks, today, &fog, &BTreeMap::new(), &BTreeSet::new());
1623 assert_eq!(made.next.as_deref(), Some("light"));
1624 assert!(["h1", "h2", "h3"].iter().all(|u| made.items[*u].start > Some(today)));
1625 }
1626
1627
1628 fn window(day: &str, start: &str, end: &str, kind: &str) -> AdminWindow {
1629 AdminWindow { day: day.into(), start: start.into(), end: Some(end.into()), minutes: 0, kind: Some(kind.into()) }
1630 }
1631
1632 #[test]
1633 fn each_kind_in_its_own_hours() {
1634 let windows = vec![window("monday", "09:00", "12:00", "work"), window("monday", "17:00", "18:00", "admin"), window("saturday", "10:00", "12:00", "leisure")];
1636 let settings = Settings { pause: 0, ..Settings::of_hours(&windows, TaskAreas::usual()) };
1637 let monday = day("2026-10-05");
1638 let tagged = |uid: &str, minutes: u32, category: &str| Task { categories: vec![category.into()], ..task(uid, minutes) };
1639 let tasks = vec![tagged("report", 150, "work"), tagged("tax", 90, "admin"), tagged("friends", 60, "friends"), tagged("more-work", 60, "work")];
1640 let made = plan(&tasks, monday, &settings, &BTreeMap::new(), &BTreeSet::new());
1641 let days = |u: &str| (made.items[u].start.unwrap().to_string(), made.items[u].finish.unwrap().to_string());
1642 assert_eq!(days("more-work"), ("2026-10-05".into(), "2026-10-05".into()));
1644 assert_eq!(days("report"), ("2026-10-05".into(), "2026-10-12".into()));
1645 assert_eq!(days("tax"), ("2026-10-05".into(), "2026-10-12".into()));
1647 assert_eq!(days("friends"), ("2026-10-05".into(), "2026-10-05".into()));
1649 assert!(settings.rest_days()[5], "Saturday has no hours");
1650 }
1651
1652 #[test]
1653 fn admin_without_hours_goes_to_work_hours() {
1654 let windows = vec![window("monday", "09:00", "10:00", "work")];
1655 let settings = Settings { pause: 0, ..Settings::of_hours(&windows, TaskAreas::usual()) };
1656 let tax = Task { categories: vec!["admin".into()], ..task("tax", 30) };
1657 let friends = Task { categories: vec!["friends".into()], ..task("friends", 30) };
1658 let made = plan(&[tax, friends], day("2026-10-05"), &settings, &BTreeMap::new(), &BTreeSet::new());
1659 assert_eq!(made.items["tax"].start, Some(day("2026-10-05")));
1660 assert_eq!(made.items["friends"].start, Some(day("2026-10-05")));
1662 assert!(settings.rest_days()[1], "Tuesday has no hours");
1663 }
1664
1665 #[test]
1666 fn meals_and_naps_are_kept_free() {
1667 let windows: Vec<AdminWindow> = ["monday"].iter().map(|d| AdminWindow { day: d.to_string(), start: "09:00".into(), end: Some("17:00".into()), minutes: 0, kind: None }).collect();
1668 let plain = Settings::of_hours(&windows, TaskAreas::usual());
1669 assert_eq!(plain.week[0].total(), 8 * 60);
1670 let needs = crate::needs::Needs { meals_on: true, naps_on: true, ..crate::needs::Needs::default() };
1672 let kept = Settings::of_hours(&windows, TaskAreas::usual()).with_needs(&needs, BTreeMap::new());
1673 assert_eq!(kept.week[0].total(), 8 * 60 - 50 - 35);
1674 let now: Zoned = "2026-10-05T08:00[Europe/Paris]".parse().unwrap();
1676 let moved = Settings::of_hours(&windows, TaskAreas::usual()).with_needs(&needs, BTreeMap::from([("meal:1".to_string(), 15)])).with_events(&now, &[]);
1677 assert_eq!(moved.room_on(now.date()).total(), 8 * 60 - 50 - 35);
1678 let lunch = moved.kept_on(now.date(), now.time_zone(), now.date()).into_iter().find(|(from, _)| *from > now.timestamp().as_second() + 3 * 3600).unwrap();
1679 assert_eq!(jiff::Timestamp::from_second(lunch.0).unwrap().to_zoned(now.time_zone().clone()).strftime("%H:%M").to_string(), "12:25");
1680 }
1681
1682 #[test]
1683 fn a_day_s_own_meals_and_naps_in_the_plan() {
1684 let windows: Vec<AdminWindow> = ["tuesday", "wednesday"].iter().map(|d| AdminWindow { day: d.to_string(), start: "09:00".into(), end: Some("17:00".into()), minutes: 0, kind: None }).collect();
1686 let needs = crate::needs::Needs { meals_on: true, naps_on: true, ..crate::needs::Needs::default() };
1687 let now: Zoned = "2026-10-05T08:00[Europe/Paris]".parse().unwrap();
1688 let (tuesday, wednesday): (Date, Date) = ("2026-10-06".parse().unwrap(), "2026-10-07".parse().unwrap());
1689 let mut days = crate::needs::Days::default();
1691 days.change(wednesday, "nap:0", |b| b.off = true);
1692 days.change(wednesday, "meal:1", |b| b.minutes = Some(60));
1693 let settings = Settings::of_hours(&windows, TaskAreas::usual()).with_needs(&needs, BTreeMap::new()).with_days(days).with_events(&now, &[]);
1694 assert_eq!(settings.room_on(tuesday).total(), 8 * 60 - 50 - 35, "Tuesday as usual");
1695 assert_eq!(settings.room_on(wednesday).total(), 8 * 60 - 80, "the nap's time given back, lunch half an hour longer");
1696 let next: Date = "2026-10-14".parse().unwrap();
1697 assert_eq!(settings.room_on(next).total(), 8 * 60 - 50 - 35, "the Wednesday after as usual");
1698 }
1699
1700 #[test]
1701 fn events_and_pauses_take_room() {
1702 let windows = vec![window("monday", "09:00", "12:00", "work")];
1704 let zone = TimeZone::get("Europe/Paris").unwrap();
1705 let now = "2026-10-05T08:00".parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone.clone()).unwrap();
1706 let at = |time: &str| format!("2026-10-05T{time}").parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone.clone()).unwrap().timestamp().as_second();
1707 let meeting = Occurrence { start: at("10:00"), end: at("11:00"), ..Occurrence::default() };
1708 let settings = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&now, &[meeting]);
1709 assert_eq!(settings.room_on(now.date()).total(), 180 - 70);
1710 let tasks = vec![task("a", 50), task("b", 50), task("c", 50)];
1712 let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
1713 let on_monday = ["a", "b", "c"].iter().filter(|u| made.items[**u].start == Some(now.date())).count();
1714 assert_eq!(on_monday, 2);
1715 let later = now.with().hour(11).minute(30).build().unwrap();
1717 let settings = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&later, &[]);
1718 assert_eq!(settings.room_on(later.date()).total(), 30);
1719 }
1720
1721 #[test]
1726 fn transparent_and_declined_events_take_no_room() {
1727 let windows = vec![window("monday", "09:00", "12:00", "work")];
1728 let zone = TimeZone::get("Europe/Paris").unwrap();
1729 let now = "2026-10-05T08:00".parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone.clone()).unwrap();
1730 let event = |uid: &str, extra: &str| format!("BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261001T000000Z\r\nDTSTART;TZID=Europe/Paris:20261005T100000\r\nDTEND;TZID=Europe/Paris:20261005T110000\r\nSUMMARY:{uid}\r\n{extra}END:VEVENT\r\n");
1731 let invited = |me: &str, other: &str| format!("ORGANIZER:mailto:host@example.org\r\nATTENDEE;PARTSTAT={me}:mailto:Me@Example.org\r\nATTENDEE;PARTSTAT={other}:mailto:guest@example.org\r\n");
1732 let dir = std::env::temp_dir().join(format!("sioul-plan-transp-{}", std::process::id()));
1733 std::fs::create_dir_all(&dir).unwrap();
1734 let calendar = crate::vdir::Collection { kind: crate::vdir::Kind::Calendars, account: "demo".into(), id: "demo".into(), dir: dir.clone(), name: "Demo".into(), color: None, read_only: false, components: vec![] };
1735 let read = |name: &str, body: String| {
1736 let path = dir.join(format!("{name}.ics"));
1737 std::fs::write(&path, format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Demo//EN\r\n{body}END:VCALENDAR\r\n")).unwrap();
1738 let mut found = crate::agenda::file_occurrences(&path, &calendar, now.timestamp().as_second(), now.timestamp().as_second() + 86_400, &zone);
1739 crate::agenda::mark_declined(&mut found, &["me@example.org".to_string()]);
1740 found
1741 };
1742 let room = |events: &[Occurrence]| Settings::of_hours(&windows, TaskAreas::usual()).with_events(&now, events).room_on(now.date()).total();
1743 let free = read("free", event("free", "TRANSP:TRANSPARENT\r\n"));
1744 let declined = read("declined", event("declined", &invited("DECLINED", "ACCEPTED")));
1745 assert!(free[0].transparent && !free[0].declined && declined[0].declined && !declined[0].transparent);
1746 assert_eq!((room(&free), room(&declined), room(&[free.clone(), declined.clone()].concat())), (180, 180, 180), "no room taken");
1747 assert!(event_spans(&[free.clone(), declined.clone()].concat(), 5).is_empty());
1748 assert_eq!((free.len(), declined.len(), free[0].start), (1, 1, declined[0].start));
1750 let busy = read("busy", event("busy", "TRANSP:OPAQUE\r\n"));
1752 let accepted = read("accepted", event("accepted", &invited("ACCEPTED", "DECLINED")));
1753 assert!(!accepted[0].declined);
1754 assert_eq!((room(&busy), room(&accepted)), (180 - 70, 180 - 70));
1755 let _ = std::fs::remove_dir_all(&dir);
1756 }
1757
1758 #[test]
1759 fn a_step_that_fills_its_window_is_not_cut() {
1760 let windows = vec![window("tuesday", "14:00", "15:00", "admin"), window("wednesday", "18:00", "18:30", "admin")];
1762 let settings = Settings::of_hours(&windows, TaskAreas::usual());
1763 let form = Task { due: "2026-10-06".into(), ..task("form", 60) };
1764 let made = plan(&[form, task("letter", 30)], day("2026-10-05"), &settings, &BTreeMap::new(), &BTreeSet::new());
1765 assert_eq!((made.items["form"].start, made.items["form"].finish, made.items["form"].on_start), (Some(day("2026-10-06")), Some(day("2026-10-06")), 60));
1766 assert_eq!((made.items["letter"].start, made.items["letter"].on_start), (Some(day("2026-10-07")), 30));
1767 }
1768
1769 #[test]
1770 fn calls_to_offices_in_working_hours() {
1771 let windows = vec![window("monday", "09:00", "12:00", "work"), window("monday", "18:00", "19:00", "admin")];
1773 let settings = Settings::of_hours(&windows, TaskAreas::usual());
1774 let call = Task { office_hours: true, ..task("call", 30) };
1775 assert_eq!(settings.usable(&call), Area::MIXED);
1776 assert_eq!(settings.usable(&task("form", 30)), Area::ADMIN);
1777 let deadline = Task { due: "2026-10-12".into(), ..task("deadline", 30) };
1779 let friends = Task { due: "2026-10-20".into(), categories: vec!["friends".into()], ..task("friends", 30) };
1780 let made = plan(&[friends, deadline], day("2026-10-05"), &settings, &BTreeMap::new(), &BTreeSet::new());
1781 assert_eq!(made.items["friends"].latest_start, Some(day("2026-10-20")));
1782 assert_eq!(made.ready.first().map(String::as_str), Some("deadline"));
1783 }
1784
1785 #[test]
1786 fn events_of_several_days_and_odd_files() {
1787 let windows = vec![window("monday", "09:00", "17:00", "work"), window("tuesday", "09:00", "17:00", "work"), window("wednesday", "09:00", "17:00", "work")];
1789 let zone = TimeZone::get("Europe/Paris").unwrap();
1790 let now = "2026-10-04T12:00".parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone.clone()).unwrap();
1791 let at = |text: &str| text.parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone.clone()).unwrap().timestamp().as_second();
1792 let trip = Occurrence { start: at("2026-10-05T08:00"), end: at("2026-10-07T20:00"), ..Occurrence::default() };
1793 let settings = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&now, &[trip]);
1794 assert_eq!(settings.room_on(day("2026-10-06")).total(), 0);
1795 let mut sent = task("sent", 15);
1797 sent.status = Status::Completed;
1798 sent.completed = Some(now.timestamp().as_second());
1799 let answer = Task { relations: vec![Relation { kind: "DEPENDS-ON".into(), uid: "sent".into(), gap: i64::MAX / 2 }], ..task("answer", 15) };
1800 let huge = Task { due: "2026-10-30".into(), ..task("huge", u32::MAX) };
1801 let made = plan(&[sent, answer, huge], now.date(), &Settings::flat([1, 1, 1, 1, 1, 0, 0]), &BTreeMap::new(), &BTreeSet::new());
1802 assert!(made.items["answer"].start.is_some() && made.items["huge"].latest_start.is_some());
1803 }
1804
1805 fn pinned(uid: &str, from: &str, to: &str) -> (String, crate::blocks::Pin) {
1807 let zone = TimeZone::get("Europe/Paris").unwrap();
1808 let at = |text: &str| text.parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone.clone()).unwrap();
1809 let (start, end) = (at(from), at(to));
1810 let pin = crate::blocks::Pin { key: format!("{uid}.ics"), uid: format!("block-{uid}"), start: start.timestamp().as_second(), end: end.timestamp().as_second(), date: start.date(), before: 0, after: 0, read_only: false };
1811 (uid.to_string(), pin)
1812 }
1813
1814 fn with_pins(pins: Vec<(String, crate::blocks::Pin)>, limit: Option<f32>) -> (Settings, Zoned) {
1816 let now: Zoned = "2026-10-05T08:00[Europe/Paris]".parse().unwrap();
1817 let mut settings = Settings { pause: 0, ..Settings::default() };
1818 settings.capacity.limit = limit.map(|l| [l; 5]);
1819 let blocks = crate::blocks::Blocks { pins: pins.into_iter().collect(), ..crate::blocks::Blocks::default() };
1820 (settings.with_pins(blocks).with_events(&now, &[]), now)
1821 }
1822
1823 #[test]
1824 fn a_task_pinned_to_a_block_is_laid_there_once() {
1825 let (wednesday, thursday) = (day("2026-10-07"), day("2026-10-08"));
1826 let (settings, now) = with_pins(vec![pinned("bank", "2026-10-07T14:00", "2026-10-07T15:00")], None);
1827 assert_eq!((settings.room_on(wednesday).total(), settings.room_on(day("2026-10-06")).total()), (480 - 60, 480));
1829 let tasks = vec![task("bank", 30), task("letter", 30)];
1830 let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
1831 assert_eq!((made.items["bank"].start, made.items["bank"].finish, made.items["bank"].on_start), (Some(wednesday), Some(wednesday), 60));
1833 assert_eq!(made.days[&wednesday].iter().filter(|(u, _)| u == "bank").count(), 1);
1834 assert!(made.days.iter().all(|(d, steps)| *d == wednesday || steps.iter().all(|(u, _)| u != "bank")));
1835 assert_eq!((made.items["letter"].start, made.pins["bank"].key.as_str()), (Some(now.date()), "bank.ics"));
1836 let (settings, now) = with_pins(vec![pinned("thesis", "2026-10-07T14:00", "2026-10-07T15:00")], None);
1838 let made = plan(&[task("thesis", 180)], now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
1839 assert_eq!((made.items["thesis"].start, made.items["thesis"].on_start, made.items["thesis"].finish), (Some(wednesday), 60, Some(thursday)));
1840 assert_eq!(made.days[&thursday], vec![("thesis".to_string(), 120)]);
1841 let (settings, now) = with_pins(vec![pinned("music", "2026-10-07T18:00", "2026-10-07T19:00")], None);
1843 let music = Task { categories: vec!["joy".into()], ..task("music", 60) };
1844 let made = plan(&[music], now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
1845 assert_eq!((made.items["music"].start, made.ready.len()), (Some(wednesday), 0));
1846 }
1847
1848 #[test]
1849 fn a_pinned_task_weighs_on_its_day_once() {
1850 let (wednesday, thursday) = (day("2026-10-07"), day("2026-10-08"));
1852 let (settings, now) = with_pins(vec![pinned("heavy", "2026-10-07T10:00", "2026-10-07T11:00")], Some(10.0));
1853 let other = Task { start: "2026-10-07".into(), ..rated("other", 60, 8) };
1854 let made = plan(&[rated("heavy", 60, 8), other.clone()], now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
1855 assert_eq!((made.items["heavy"].start, made.items["other"].start), (Some(wednesday), Some(thursday)));
1857 let (free, _) = with_pins(Vec::new(), Some(10.0));
1859 let made = plan(&[other], now.date(), &free, &BTreeMap::new(), &BTreeSet::new());
1860 assert_eq!(made.items["other"].start, Some(wednesday));
1861 let (settings, now) = with_pins(vec![pinned("bank", "2026-10-07T14:00", "2026-10-07T15:00")], None);
1863 let bank = Task { due: "2026-10-07".into(), ..task("bank", 60) };
1864 let made = plan(std::slice::from_ref(&bank), now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
1865 let budget = cushion(std::slice::from_ref(&bank), &made, &settings, now.date(), wednesday);
1866 assert_eq!((budget.need, budget.room), (0, 480 + 480 + 420));
1867 }
1868
1869 #[test]
1870 fn overlapping_hours_open_to_both() {
1871 let windows = vec![window("saturday", "10:00", "18:00", "work"), window("saturday", "14:00", "16:00", "admin")];
1873 let settings = Settings::of_hours(&windows, TaskAreas::usual());
1874 let saturday = &settings.week[5];
1875 assert_eq!(saturday.for_kinds(Area::WORK), 480);
1876 assert_eq!(saturday.for_kinds(Area::ADMIN), 120);
1877 assert_eq!(saturday.total(), 480, "the overlap counted once");
1878 assert_eq!(saturday.for_kinds(Area::LEISURE), 0, "leisure has no hours");
1879 }
1880}