Skip to main content

sioul_core/
plan.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The plan: what can start, the one next step, and when each task could
5//! happen. It is computed from the tasks each time, never stored, so it
6//! always starts from today (docs/tasks.md).
7//!
8//! - **Order**: a task waits for others (`tasks`). Kahn's algorithm orders
9//!   them (Kahn 1962, "Topological sorting of large networks"). Tasks that
10//!   wait for each other in a loop are found (Tarjan 1972) and said, calmly:
11//!   one of them has to go first.
12//! - **The next step**: among the tasks free to start, the one already
13//!   started; else the one whose latest start comes first (its date, minus the
14//!   chain of work behind it); then the more important, as you ranked it
15//!   (PRIORITY); then the one that frees the most others; then the smaller
16//!   (docs/design.md, "Tasks").
17//! - **When**: each task goes into the first days with room, in that order,
18//!   after what it waits for, never before today. A date that passed moves a
19//!   task forward; it never makes it "late". Room is your hours, each kind for
20//!   its own tasks: work hours for work, admin hours for your admin
21//!   (`areas::in_view` lends what has no hours of its own); leisure has no
22//!   hours, so what is only for it takes no room and waits for none; less
23//!   the times kept for meals, naps and sleep (`needs`: set first, the work
24//!   planned around them), the events in them and a pause after each step. Sioul can run your work
25//!   too, so the plan may fill your hours: no second budget to keep.
26//! - **Waiting on others**: a task done starts the clock of what waits for it
27//!   with a gap ("the answer comes within two weeks"): the next one waits
28//!   until then, and says so.
29//! - **Optional**: a task tagged `joy` (only if you want to, never a duty) or
30//!   `someday` (parked: when you say so), or a step of one, is never proposed
31//!   as the next step and takes no room in the plan. Nor does what gives
32//!   back (energy "rest": a walk, music): it is offered after a heavy step.
33//! - **Heavy**: a day takes so many heavy tasks (two when clear, one in haze,
34//!   none in fog, today's weather saying for today); the next one goes to a
35//!   day that can take it, and in fog a heavy task is not the next step unless
36//!   nothing else is ready (energy accounting: fewer demands, Raymaker et al. 2020).
37//!   Heavy is the highest cost when any is rated, else the word (`capacity::level_of`);
38//!   the days before and after a heavy event take one fewer.
39//! - **What a day holds** (`capacity`, docs/capacity.md): with budgets, each day
40//!   is filled to 85 % of each cost's budget and of the total, after its events;
41//!   lighter around a heavy event, today by its weather; kept even if you
42//!   asked. Each task is laid at its corrected length (your estimate × how
43//!   long tasks like it take you), each day keeps free time for steps running
44//!   long (the 85th percentile of the total of the steps laid there, fitted
45//!   as the day fills, a third of its room at most) and half an hour for a
46//!   gain slot; the next step keeps part of today all the same.
47//!   A task with margins is never cut: laid whole on one day, or given a day
48//!   of its own. A step given a time today by hand stays today.
49
50use 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/// Where a task stands, as the board shows it.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)]
63#[serde(rename_all = "lowercase")]
64pub enum Column {
65    /// Free to start.
66    #[default]
67    Ready,
68    /// Started.
69    Doing,
70    /// Waits for another task, or for a day.
71    Waiting,
72    /// Done, or dropped.
73    Done,
74}
75
76/// The least a step takes in a day, in minutes: a short one gets air around it.
77pub const SHORTEST: u32 = 15;
78
79/// A pause after each step and around each event, in minutes: switching costs (monotropism).
80pub const PAUSE: u32 = 5;
81
82/// A step up to an hour is done in one go, never cut over two days or around a break.
83pub const WHOLE: u32 = 60;
84
85/// A day's hours for tasks: minutes, by the kinds of hours open then
86/// (several where windows overlap: admin hours within working hours).
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct Room(pub Vec<(Area, u32)>);
89
90impl Room {
91    /// Minutes open to something of `kinds`.
92    pub fn for_kinds(&self, kinds: Area) -> u32 {
93        self.0.iter().filter(|(open, _)| open.meets(kinds)).map(|(_, minutes)| minutes).sum()
94    }
95
96    /// Every minute of it.
97    pub fn total(&self) -> u32 {
98        self.0.iter().map(|(_, minutes)| minutes).sum()
99    }
100
101    /// The minutes of `stretches`, gathered by the kinds open in each.
102    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
115/// The stretches of `date`'s hours, as (start, end, kinds open), Unix seconds,
116/// in order: where windows of several kinds overlap, one stretch open to each;
117/// with `anything`, every window is open to every kind.
118pub 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
141/// `stretches` outside every `busy` span.
142pub 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
162/// The time events hold: their margins (getting there and back, getting
163/// ready: `demands`), then a pause before and after; timed ones that hold
164/// time (`Occurrence::holds_time`: not cancelled, not transparent, not
165/// declined by you); shorter than a quarter of an hour, a quarter of an hour.
166pub 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/// How much room the days have for tasks: your hours, each for what it is for.
176#[derive(Debug, Clone)]
177pub struct Settings {
178    /// The week's hours as room, Monday first.
179    pub week: [Room; 7],
180    /// Days whose room is not their weekday's: today from now on, the days
181    /// with events, the events taken out (`with_events`).
182    pub days: BTreeMap<Date, Room>,
183    /// The week's hours as written, for the day's layout (`dayview`).
184    pub windows: Vec<AdminWindow>,
185    /// No hours set at all: offices' usual hours, open to every task.
186    pub anything: bool,
187    /// Which kinds of hours the week sets: what has none of its own goes where `areas::in_view` says.
188    pub hours: Week,
189    /// What each task is for: its own area, else its categories and projects.
190    pub areas: TaskAreas,
191    /// Minutes of pause after each step, taken from the room too.
192    pub pause: u32,
193    /// Minutes counted for a task without an estimate.
194    pub default_estimate: u32,
195    /// Today's share of its usual room, in percent: the day's weather (`today`).
196    pub today_percent: u32,
197    /// Days without room, whatever their weekday: time off, a day stopped early.
198    pub closed: BTreeSet<Date>,
199    /// The weekdays offices open, Monday first: tasks that need one go only there.
200    pub office_days: [bool; 7],
201    /// Offices' usual hours (`Config::office_hours`): the day's layout puts a
202    /// task that needs an office, without hours of its own, only while they are open.
203    pub office_hours: Vec<AdminWindow>,
204    /// Heavy tasks today takes (its weather), and any other day.
205    pub heavy_today: u32,
206    pub heavy_per_day: u32,
207    /// Meals, naps and the night: kept free of tasks (`with_needs`).
208    pub needs: crate::needs::Needs,
209    /// Today's meals pushed past its events, by key, in minutes (`Needs::past_events_on`).
210    pub shifts: BTreeMap<String, i64>,
211    /// Each day's own meals, naps and nights: moved, changed, taken out, added (`with_days`).
212    pub needs_days: crate::needs::Days,
213    /// What your record says (`capacity`): ratings, corrected lengths, budgets,
214    /// free time, the gain slot. Its default changes nothing.
215    pub capacity: crate::capacity::Planning,
216    /// Today's end of work moved by free time: (usual end, moved end), Unix
217    /// seconds (`with_extension`, docs/pauses.md). Hours, not capacity: the
218    /// budgets do not see it, and only light steps go there.
219    pub extension: Option<(i64, i64)>,
220    /// Minutes of today's room in it, kept for light steps (`with_events`).
221    pub light_only: u32,
222    /// Tasks pinned to a time (`blocks`), by UID: each laid in its block, the
223    /// block's time out of its day's room (`with_pins`, before `with_events`).
224    pub pins: BTreeMap<String, crate::blocks::Pin>,
225    /// Tasks done, by UID: the block that held their work, (start, end) in
226    /// Unix seconds, where the day shows them done.
227    pub records: BTreeMap<String, (i64, i64)>,
228}
229
230impl Default for Settings {
231    /// Offices' usual hours, Monday to Friday 9:00 to 17:00, open to every task, until yours are set.
232    fn default() -> Settings {
233        Settings::of_hours(&[], TaskAreas::usual())
234    }
235}
236
237impl Settings {
238    /// The room your week's hours give (`Config::week_hours`), each kind for
239    /// its own tasks; none set: offices' usual hours, for everything.
240    pub fn of_hours(windows: &[AdminWindow], areas: TaskAreas) -> Settings {
241        // Leisure is every other time: an older Sioul's free-time hours give no room.
242        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        // Any week will do: Monday 1 January 2024 to Sunday the 7th.
247        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    /// The same minutes each weekday, Monday first, open to every task, no pause: for tests and simple plans.
275    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    /// Meals, naps and the night kept free of tasks, every day (`shifts`:
281    /// today's moved by its events, by key, in minutes). Before `with_events`,
282    /// which keeps them free on the days it lays out too.
283    pub fn with_needs(mut self, needs: &crate::needs::Needs, shifts: BTreeMap<String, i64>) -> Settings {
284        let none = |_: &str| 0;
285        // Any week will do, as for the hours: Monday 1 January 2024, on the clock.
286        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    /// Each day's own meals, naps and nights (`needs::Days`): before
296    /// `with_events`, which lays out each day changed on its own.
297    pub fn with_days(mut self, days: crate::needs::Days) -> Settings {
298        self.needs_days = days;
299        self
300    }
301
302    /// The tasks pinned to a time (`blocks::Blocks`): before `with_events`,
303    /// which takes each block's time, its margins and a pause around it, out of
304    /// its day's room, as an event's. Counted once: the plan lays the task
305    /// there without taking room again, and the block is no event of its own.
306    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    /// The times kept free on `date`, as that day has them; today's meals
313    /// moved past its events as said (`shifts`).
314    pub fn kept_on(&self, date: Date, zone: &TimeZone, today: Date) -> Vec<(i64, i64)> {
315        self.kept_held(date, zone, today, &[])
316    }
317
318    /// The same, another day's meals moved past the events it holds (`held`:
319    /// their times with their margins), as today's are.
320    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    /// Today's end of work moved by free time, (usual end, moved end): before
326    /// `with_events`, which gives today the room, light steps only there.
327    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    /// The moved end's stretch on `date`, outside the hours already there: none
333    /// on another day (docs/pauses.md).
334    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    /// `date`'s hours as stretches, in order, the moved end of work included.
347    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    /// Today from `now` on, and the coming days with events: the events taken
355    /// out of their hours, a pause before and after each (appointments, meetings).
356    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        // The blocks tasks are pinned to: their time held as an event's, a pause around it.
361        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        // Every day an event covers (a trip, three days of a conference), and
365        // the day before, whose hours may run past midnight; as far as the plan goes.
366        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        // Each day with changes of its own, and the next (its night ends that morning).
378        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        // Meals move past the events with their margins, as today's (`Needs::past_events_on`).
382        let mut held = event_spans(events, 0);
383        // Past the blocks tasks are pinned to as well, as the Health page moves them.
384        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                // From the next five minutes, as the day's layout starts.
390                taken.push((i64::MIN, (now.timestamp().as_second() + 299) / 300 * 300));
391            }
392            let usual = stretches(&self.windows, date, &zone, self.anything);
393            // The end of work moved by free time today: room for light steps only.
394            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    /// A day's room: its own when it has events or is today, else its weekday's.
405    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    /// The kinds of hours a task can take: those of its area, and those
410    /// `areas::in_view` lends what has no hours of its own. A call to an
411    /// office, as `quiet::QuietTasks::keeps` shows it: work and admin hours,
412    /// never leisure, as offices keep business hours (docs/areas.md).
413    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    /// The weekdays without any hours, Monday first.
425    pub fn rest_days(&self) -> [bool; 7] {
426        std::array::from_fn(|d| self.week[d].total() == 0)
427    }
428}
429
430/// One task in the plan.
431#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
432pub struct Planned {
433    pub uid: String,
434    pub column: Column,
435    /// The open tasks it waits for: its own, and those its bigger task waits for.
436    pub waits_for: Vec<String>,
437    /// It cannot start before this day: its start date, or the end of a gap after a task done.
438    pub not_before: Option<Date>,
439    /// The open tasks that wait for it, directly or further down the chain.
440    pub unblocks: usize,
441    /// The last day it can start for every date after it to hold.
442    pub latest_start: Option<Date>,
443    /// The days the plan gives it.
444    pub start: Option<Date>,
445    pub finish: Option<Date>,
446    /// At this pace it ends after its date: the plan says so early.
447    pub tight: bool,
448    /// It waits for a task that waits for it.
449    pub in_loop: bool,
450    /// Its steps not done yet.
451    pub open_steps: usize,
452    /// How many bigger tasks it is a step of.
453    pub depth: usize,
454    /// Minutes left: its estimate, less the time already spent, its margins added.
455    pub left: u32,
456    /// Minutes the plan lays for it: the same from its corrected length (`capacity::Ratios`).
457    pub laid: u32,
458    /// How heavy the plan takes it: "light", "usual", "heavy", "rest" (`capacity::Rates::level`).
459    pub level: &'static str,
460    /// Minutes the plan gives it on its first day: all of it, or the part that fits.
461    pub on_start: u32,
462    /// Tagged optional (`words.tasks.optional`: "joy", "someday"), or a step
463    /// of such a task: never proposed, never scheduled.
464    pub optional: bool,
465}
466
467/// Whether a task's own tags make it optional (`words.tasks.optional`, the
468/// words in use: "joy", "someday"), or it gives back rather than takes (its
469/// word, or its costs rated light with a gain of 5 or more). A date asked
470/// makes it a duty all the same: planned, as a light step.
471pub 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/// The whole plan.
476#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
477pub struct Plan {
478    /// Every task, by UID.
479    pub items: BTreeMap<String, Planned>,
480    /// The open tasks, in the order the plan takes them.
481    pub order: Vec<String>,
482    /// The tasks free to start, the next one first.
483    pub ready: Vec<String>,
484    /// The next step: the first of `ready` not set aside for today, from
485    /// another stream than those set aside when there is one.
486    pub next: Option<String>,
487    /// The stream of each task, by UID: tasks tied by what they wait for or by
488    /// being steps of the same bigger task share one. "Not now" puts a whole
489    /// stream aside for the day: what comes next is not tied to it.
490    pub streams: BTreeMap<String, usize>,
491    /// Tasks that wait for each other, loop by loop.
492    pub loops: Vec<Vec<String>>,
493    /// What each day holds: each task's UID and its minutes there, in the plan's order.
494    pub days: BTreeMap<Date, Vec<(String, u32)>>,
495    /// Minutes each day keeps free for steps running long (`capacity::slack`).
496    pub slack: BTreeMap<Date, u32>,
497    /// The open tasks pinned to a time, by UID: their block (`blocks`).
498    pub pins: BTreeMap<String, crate::blocks::Pin>,
499}
500
501/// The time budget until a date asked (Shovel's "cushion"): what must be done
502/// by then, against the room the days have until then. Shown only near a date:
503/// further away it is noise (docs/tasks.md).
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
505pub struct Cushion {
506    pub date: Date,
507    /// Minutes left of every open task asked by then, of what each waits for, of its steps.
508    pub need: u32,
509    /// Minutes of room from today to that day, today's weather and days off counted.
510    pub room: u32,
511}
512
513/// The time budget until `date`: the tasks asked by then (optional ones aside),
514/// with what they wait for and their steps, against each day's room until then.
515pub 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    // The hours these tasks can take.
521    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        // Pinned to a time by then: its block's time is out of the room already, counted once.
528        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
547/// `days` later (earlier when negative); a span too long for any date (a gap
548/// or an estimate from someone else's file) moves nothing.
549fn 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
553/// Whole days of a gap: "P14D" waits fourteen days; less than a day, none.
554fn gap_days(minutes: i64) -> i64 {
555    minutes / 1440
556}
557
558/// A day as a number, to rank by.
559fn day_number(date: Date) -> i64 {
560    date.since(Date::constant(1970, 1, 1)).map_or(0, |s| i64::from(s.get_days()))
561}
562
563/// The ties between tasks, by index: who waits for whom, and who is a step of whom.
564struct Graph {
565    /// predecessor → (successor, gap in minutes): dependencies only.
566    after: Vec<Vec<(usize, i64)>>,
567    /// The bigger task of each.
568    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                    // In the one that waits.
586                    "DEPENDS-ON" => edge(other, i, relation.gap, &mut graph),
587                    // In the one that comes first (RFC 9253 §4).
588                    "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        // A step of itself, further up: the tie is dropped.
596        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    /// What a task waits for: its own predecessors and those of its bigger tasks.
626    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
641/// Strongly connected components with more than one task (Tarjan 1972): the loops.
642fn 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/// How the plan ranks two tasks: smaller comes first.
693#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
694struct Rank {
695    set_aside: bool,
696    waiting: bool,
697    /// Heavy, on a day that takes none: after the others.
698    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
709/// The plan for `tasks`, from `today`. `spent` gives the minutes already
710/// spent on each task (by UID); `set_aside` the tasks put off for today ("Not now").
711pub fn plan(tasks: &[Task], today: Date, settings: &Settings, spent: &BTreeMap<String, u32>, set_aside: &BTreeSet<String>) -> Plan {
712    // One task per UID: the first one read.
713    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    // The order's ties, among open tasks: what each waits for, and a step before its bigger task.
730    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    // Inside a loop, the ties are set aside to order the rest.
746    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    // Minutes left, and the day each can start from: as you estimated (shown,
751    // the focus timer's), and as the plan lays it (its corrected length).
752    let minutes_left = |i: usize, corrected: bool| -> u32 {
753        if open_steps[i] > 0 {
754            return 0;
755        }
756        // Every step done: only the bigger task's own tick is left.
757        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        // Getting there and back, getting ready: room taken too, never a pause.
763        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    // A plain order first, to compute latest starts backwards.
786    let topological = kahn(n, &order_edges, &open, |i| (tasks[i].created, tasks[i].title.clone(), i));
787    // The hours each task can take, and a day's room of them on average.
788    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        // The days its work takes at the pace of its hours; without hours for it, none
801        // (not a minute a day, which made such a task the most pressing of all).
802        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    // How many open tasks wait for each, further down too.
807    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    // How heavy each task is planned: its ratings (said after it, else forecast), else its word.
830    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    // Light steps alone may take today's end of work moved by free time (docs/pauses.md).
834    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    // Free to do now: nothing it waits for still open, no day ahead to wait for;
856    // a step started too soon waits like the others.
857    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    // Streams: tasks tied by waiting or by being steps of one bigger task (union-find).
861    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    // The next step: it keeps part of today (below), so that Now and the day agree.
880    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    // The plan's order, then the days: first days with room, after what each waits for.
883    let order = kahn(n, &order_edges, &open, rank);
884    // The days each task's office opens: its own hours, else offices' usual days.
885    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    // Pinned to a time (a block, `blocks`): on the block's day, its time held there already (`with_pins`).
893    let pins: Vec<Option<&crate::blocks::Pin>> = tasks.iter().map(|t| settings.pins.get(&t.uid).filter(|_| t.status.is_open())).collect();
894    // A step given a time today by a drag before blocks (`Task::at`, read still): today, whatever the room.
895    let pinned: Vec<bool> = tasks.iter().enumerate().map(|(i, t)| pins[i].is_none() && t.at_on(today, &TimeZone::system()).is_some()).collect();
896    // A day's room for tasks, in minutes: today's by its weather.
897    let room_for_tasks = |day: Date| settings.room_on(day).total() * if day == today { settings.today_percent } else { 100 } / 100;
898    // The half hour kept on a day for the slot of time for you after its costliest block.
899    let gain_kept = |day: Date| if settings.room_on(day).total() >= 120 { capacity.gain_slot } else { 0 };
900    // How a day's total spreads (`capacity::Spread`): today's as its finished tasks say it goes.
901    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    // Each task into the first days with room, each day keeping free time for its
906    // steps running long as it fills; `even`, the most a day holds.
907    let layout = |even: Option<&Load>| -> Layout {
908        // Minutes taken from each day's room, kind by kind (as `Settings::room_on` lists them).
909        let mut used: BTreeMap<Date, Vec<u32>> = BTreeMap::new();
910        let mut heavy_used: BTreeMap<Date, u32> = BTreeMap::new();
911        // Today's minutes for light steps only, taken by them first.
912        let mut light_used: u32 = 0;
913        // What the tasks laid each day weigh (`capacity::Load`).
914        let mut loaded: BTreeMap<Date, Load> = BTreeMap::new();
915        // The minutes laid each day, summed and squared: what its free time grows with.
916        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        // Pinned to a time, first, so that what each day holds sees them: on its block's day, the block's
919        // minutes and its margins, whatever the room, the budgets or what it waits for. No room is taken:
920        // its time is out of the day's room already (`Settings::with_pins`).
921        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            // Pinned (laid above): a bigger task spans its block and its steps; what its block does not hold of
944            // a step goes on from the next day, as any step (a quarter of an hour at least).
945            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                // A bigger task spans its steps.
966                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            // Given a time today by hand: today, whole, whatever the room, the budgets or what it waits for.
973            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            // The most room a day it can go on has for it: its kinds' hours, on its office's days.
993            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            // No hours ever for what it is for (on days its office opens): it waits for none.
995            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            // Done in one go when it is short, or has margins (going there twice is
1002            // not the same step), and some day can hold it.
1003            let whole = (minutes <= WHOLE || margins) && minutes <= biggest;
1004            // With margins and longer than any day's room: a day of its own.
1005            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                // A day full of heavy tasks takes no other; the days around a heavy event, one fewer.
1010                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                    // The hours of its kinds, those open to fewer kinds first: overlaps stay for either.
1018                    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                    // Room for the step and the pause after it; the day's first step may fill its
1022                    // hours without one (a step of an hour in an hour's window is not cut).
1023                    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                    // The moved end's minutes not taken yet stay for light steps.
1027                    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                    // Kept free: the slot of time for you after the day's costliest block, and time for
1030                    // steps running long: the day, this part laid, must still hold the 85th percentile
1031                    // of its total (`capacity::slack_estimate`), a third of its room at most.
1032                    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                    // The next step keeps part of today, as before free time was kept: what is left of
1055                    // today's room is its when the time kept free would leave it none. Only a step that
1056                    // cannot be cut (its margins) goes whole to the first day that holds it.
1057                    if part < minutes.min(SHORTEST) && !alone && next == Some(i) && day == today {
1058                        part = shaped(open_here, minutes, whole, alone, first);
1059                    }
1060                    // What the day's costs still hold (docs/capacity.md, "Planning").
1061                    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                            // Too heavy for an empty ordinary day (no weather, no event around it):
1079                            // the first day with no other task takes it all the same.
1080                            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                                // The part the budgets let in, a quarter of an hour at least, a quarter left for later.
1088                                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        // What each day has left once its steps and its slot for you are laid.
1129        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    // Laid once, each day keeping free time for its steps running long as it
1133    // fills (TE16–18); days kept even, again, each day held to the coming
1134    // week's mean (criterion 35).
1135    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            // A cost nothing weighs on this week stays unbounded.
1149            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    // The free time each day keeps: the 85th percentile of the total of the steps
1154    // laid there, by the seeded simulation (`capacity::slack`), a third of its
1155    // room at most, and never more than it has left.
1156    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
1209/// Where the tasks went: their first and last days, the minutes of their first,
1210/// what each day holds (task index, minutes), and what it has left.
1211struct 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
1219/// The part of a step worth starting in `free` minutes: the whole rest; for a
1220/// long step, a quarter of an hour at least, leaving at least as much for
1221/// later; a step done in one go (`whole`), all or nothing; one that needs a
1222/// day of its own (`alone`), all of an untouched day.
1223fn 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
1234/// Rating × hours, per cost and in total (`capacity::Load`).
1235type Load = crate::capacity::Load;
1236
1237/// Kahn's algorithm over the open tasks, the smallest key first among those free.
1238fn 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        // Saturday 3 October 2026.
1268        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        // Monday off: Tuesday.
1274        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        // Saturday 3 October: an office open on Saturday mornings takes the call today.
1293        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        // A request with two steps: one free, one waiting for a certificate, which waits for a call.
1306        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        // Something else entirely.
1312        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        // The call put off: the free form is tied to it, the shelf is not.
1320        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        // Nothing else: the tied step all the same.
1323        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    /// Saturday 3 October 2026.
1342    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        // A date first; then what frees the most; then the smaller.
1368        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        // An hour on weekdays: Saturday the 3rd and Sunday the 4th have none.
1378        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        // "late" ranks first (its date), and fills Monday's first 45 minutes.
1389        assert_eq!(days("late"), ("2026-10-05".into(), "2026-10-05".into()));
1390        assert!(!plan.items["late"].tight);
1391        // "one" is done in one go: not in Monday's last 15 minutes, on Tuesday; "two" follows on Wednesday.
1392        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        // Two weeks after the letter went: not before the 15th.
1395        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        // The steps wait for what their bigger task waits for; what waits for it, for its steps.
1424        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); // a Monday
1434        let task = |uid: &str, estimate: u32, due: &str| Task { uid: uid.into(), title: uid.into(), estimate, due: due.into(), ..Task::default() };
1435        // Asked for Wednesday: a form (1 h) that waits for a scan (30 min), and two steps of a letter (20 min each, the letter's own time not counted).
1436        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        // Asked later, and a joy: neither counts.
1445        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        // Monday at half (the weather), Tuesday and Wednesday whole.
1455        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        // A budget of 10 a day: 8.5 planned. Three hours rated 8 (cognitive): one a day.
1473        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        // Without a budget, all today, as before.
1479        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        // Light ones fill around: a long light step goes on with the budget's room, cut over days.
1482        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        // Haze holds 60 %: the hour rated 8 waits for a day that holds it.
1486        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        // A budget of 4 (3.4 planned): an hour rated 8 never fits; a small step first today.
1496        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        // Off by default.
1515        assert!(!Settings::default().capacity.even);
1516    }
1517
1518    #[test]
1519    fn a_heavy_event_lightens_the_days_around_it() {
1520        // Tomorrow is the day before a heavy appointment: one heavy task fewer.
1521        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        // With budgets, those days hold three quarters.
1530        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        // An hour a day: 40 minutes and a quarter of an hour each way (70) is never cut.
1539        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        // Without margins, the same length goes on over two days.
1545        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        // Rated light with a gain: it gives back, offered rather than planned; with a date asked, planned all the same.
1557        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        // Three half hours on a day of eight hours: the free time kept is the simulation's, on the steps laid there.
1566        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        // Days kept even move steps: each day's free time follows what ends up there.
1578        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        // No spread, no free time: as before.
1589        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        // Forty minutes left today, the widest free time: the next step still takes part of today.
1595        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        // With margins it is never cut: whole, on the first day that holds it.
1603        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); // a Monday
1611        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        // Clear: two heavy today, the third tomorrow; the walk takes no room and is never the next step.
1614        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        // Fog: no heavy task today, and the light one comes first.
1621        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        // Monday 5 October: work 9–12, admin 17–18; an older Sioul's free time on Saturday, 10–12, left aside.
1635        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        // Work fills Monday's work hours, the smaller step first; the report goes on the next Monday (Tuesday has no hours).
1643        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        // Admin in admin hours only: an hour on Monday, the rest the next Monday.
1646        assert_eq!(days("tax"), ("2026-10-05".into(), "2026-10-12".into()));
1647        // Leisure has no hours: it waits for none, and takes nothing from work.
1648        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        // Leisure has no hours: it waits for none, and takes nothing from work.
1661        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        // Lunch kept from 12:10 (getting it ready) to 13:00, a nap from 14:00 to 14:35: 85 minutes fewer.
1671        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        // Today, lunch moved a quarter of an hour later: the same minutes kept, later.
1675        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        // Work 9:00–17:00 on Tuesdays and Wednesdays; lunch kept 12:10–13:00, a nap 14:00–14:35.
1685        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        // That Wednesday only: no nap, and an hour for lunch.
1690        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        // Monday 5 October, 8:00: work 9–12 (180 min), a meeting 10–11 with a pause before and after.
1703        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        // Two 50-minute steps fit with their pauses (55 + 55 = 110); a third goes to the next Monday.
1711        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        // Later today, what passed is gone too.
1716        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    /// A transparent event (TRANSP:TRANSPARENT) and an invitation you declined
1722    /// (your PARTSTAT=DECLINED) hold no time in the plan, read from their
1723    /// files as the agenda reads them; both are still listed, as before. One
1724    /// declined by another guest holds its time, and so does one you accepted.
1725    #[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        // Still events of the day: listed, with their times.
1749        assert_eq!((free.len(), declined.len(), free[0].start), (1, 1, declined[0].start));
1750        // Opaque, accepted, or declined by someone else: an hour and its pauses taken, as before.
1751        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        // Tuesday 14:00–15:00 and Wednesday 18:00–18:30 for your admin: an hour's step and a half hour's, each whole in its window.
1761        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        // Admin hours in the evening only: a call to an office still goes in working hours (docs/areas.md).
1772        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        // A date asked, and a task with no hours for it (leisure here): the date comes first, not the one without hours.
1778        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        // A trip from Monday 8:00 to Wednesday 20:00 takes Tuesday's hours too.
1788        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        // A gap and an estimate too long for any date, from someone else's file: planned, no crash.
1796        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    /// A task pinned to a block from `from` to `to` ("2026-10-07T14:00", in Paris), by its UID.
1806    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    /// Offices' usual hours (Monday to Friday, 9:00 to 17:00), no pause, from Monday 5 October 8:00, with these pins.
1815    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        // Its hour is out of Wednesday's room, as an event's would be: once.
1828        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        // Laid on its block's day, for the block's hour, once, whatever its estimate; the rest as before.
1832        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        // Longer than its block: the rest from the next day, as any step.
1837        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        // Something to do only if you want: pinned all the same, the time is yours.
1842        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        // A budget of 10 a day (8.5 laid); "heavy" rated 8 pinned on Wednesday for an hour; "other" rated 8, from Wednesday.
1851        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        // What Wednesday holds sees the pinned hour first: the other goes on Thursday.
1856        assert_eq!((made.items["heavy"].start, made.items["other"].start), (Some(wednesday), Some(thursday)));
1857        // Unpinned, it would have taken Wednesday.
1858        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        // The time budget until Wednesday counts the pinned hour once: its time is out of the room already.
1862        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        // Saturday work 10–18 with admin hours 14–16 inside.
1872        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}