Skip to main content

sioul_core/
dayview.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The day, seen (docs/tasks.md, "The day"): today's events at their times,
5//! and the steps the plan gives today laid into your hours, each into hours
6//! of its own kind (work, admin), a pause between them and around
7//! the events; a line where now is. A step up to an hour is never cut; a
8//! longer one goes on after a break; one with margins never (going there
9//! twice is not the same step). Visual supports cut transition time
10//! (Dettmer et al. 2000); the current step is shown inside the whole day,
11//! with what comes next (Brili, Tiimo). A layout to look at, never a schedule
12//! to keep: nothing is written into the tasks, and a step that runs over
13//! only moves the next ones.
14//!
15//! What a day asks is spread (docs/capacity.md): never two heavy steps in a
16//! row, a light step or a break of a quarter of an hour after a heavy one,
17//! two steps weighing on the same cost apart when another can come between
18//! (criteria 29–30). A step given a time by hand keeps it. Two slots of time
19//! for you are kept, silent: one after the day's costliest block, one in the
20//! evening (G11, G15, G18). Free time is kept after the last step for steps
21//! running long (`plan::Plan::slack`).
22
23use crate::agenda::Occurrence;
24use crate::areas::Area;
25use crate::plan::{Plan, SHORTEST, Settings, WHOLE};
26use crate::tasks::Task;
27use jiff::Zoned;
28use serde::Serialize;
29use std::collections::BTreeMap;
30
31/// One block of the day.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct Block {
34    /// Unix seconds.
35    pub start: i64,
36    pub end: i64,
37    /// "event", "task", "meal", "nap", "margin" (getting there and back, getting
38    /// ready), "done" (a task done today, kept in view).
39    pub kind: &'static str,
40    pub title: String,
41    /// A task's UID, an event's file.
42    pub key: String,
43    /// A task "heavy", "light" or "rest"; "" otherwise.
44    pub energy: String,
45    pub location: String,
46    /// Side by side with what overlaps it (two events at once): its column among `columns`.
47    pub column: u32,
48    pub columns: u32,
49    /// A long step cut by a break: its part, from 1; 0 when whole.
50    pub part: u32,
51    /// A step given this time by hand: laid there, whatever else is.
52    pub pinned: bool,
53    /// A line under the title: a gain slot's suggestion; "" otherwise.
54    pub note: String,
55    /// An event (or its margins) whose calendar cannot be written here; false for anything else.
56    pub read_only: bool,
57    /// An event (or its margins) that comes back: one occurrence of it; false for anything else.
58    pub recurring: bool,
59}
60
61/// A stretch of the day's hours and what it is for: "work", "admin",
62/// "leisure", several joined by "+" where hours overlap.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct Stretch {
65    pub start: i64,
66    pub end: i64,
67    pub kind: String,
68}
69
70/// Places spans side by side where they overlap, as (column, columns): each
71/// takes the first column free at its start, and every span of a group of
72/// overlapping ones learns how many columns the group needs. `spans` are
73/// (start, end), in any unit, an end no earlier than its start.
74pub fn side_by_side(spans: &[(i64, i64)]) -> Vec<(u32, u32)> {
75    let mut out = vec![(0u32, 1u32); spans.len()];
76    let mut order: Vec<usize> = (0..spans.len()).collect();
77    order.sort_by_key(|&i| (spans[i].0, -spans[i].1));
78    let mut group: Vec<usize> = Vec::new();
79    let mut ends: Vec<i64> = Vec::new();
80    let mut group_end = i64::MIN;
81    let close = |out: &mut Vec<(u32, u32)>, group: &mut Vec<usize>, ends: &mut Vec<i64>| {
82        let columns = u32::try_from(ends.len()).unwrap_or(1).max(1);
83        for &i in group.iter() {
84            out[i].1 = columns;
85        }
86        group.clear();
87        ends.clear();
88    };
89    for i in order {
90        let (from, to) = spans[i];
91        if from >= group_end {
92            close(&mut out, &mut group, &mut ends);
93            group_end = i64::MIN;
94        }
95        let column = match ends.iter().position(|&end| end <= from) {
96            Some(free) => {
97                ends[free] = to;
98                free
99            }
100            None => {
101                ends.push(to);
102                ends.len() - 1
103            }
104        };
105        out[i].0 = u32::try_from(column).unwrap_or(0);
106        group.push(i);
107        group_end = group_end.max(to);
108    }
109    close(&mut out, &mut group, &mut ends);
110    out
111}
112
113/// The day: from its first hour to its last, its hours, its blocks, and now.
114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
115pub struct DayView {
116    pub from: i64,
117    pub to: i64,
118    pub now: i64,
119    /// Today's hours, by what they are for.
120    pub hours: Vec<Stretch>,
121    pub blocks: Vec<Block>,
122    /// Events of the whole day, said above.
123    pub all_day: Vec<String>,
124    /// Steps of today that do not fit before the day ends: they keep their place in the plan.
125    pub more: usize,
126    /// Why today holds what it holds, in at most two lines (`capacity::reasons`); set by the caller.
127    pub said: Vec<String>,
128}
129
130/// After a heavy step, this long before anything but a light one (criterion 29: more than 10 min).
131pub const BREAK_AFTER_HEAVY: i64 = 15;
132/// How far ahead the day looks for a step that keeps two heavy ones, or two
133/// of the same cost, apart. A guess.
134pub const LOOK_AHEAD: usize = 3;
135/// A gain slot: this long, a quarter of an hour when that is all a gap holds (G11).
136pub const GAIN_LEAST: i64 = 15;
137/// The free time kept for steps running long is shown from this long.
138pub const SLACK_SHOWN: i64 = 10;
139
140/// A step of today, as the day orders it.
141#[derive(Debug, Clone)]
142struct Step<'a> {
143    task: &'a Task,
144    planned: &'a crate::plan::Planned,
145    level: crate::demands::Level,
146    dominant: Option<usize>,
147}
148
149/// Today's steps in the plan's order, moved only to keep two heavy ones, or
150/// two weighing on the same cost, apart, when one of the next few can come
151/// between; never before what it waits for.
152fn spread_out<'a>(mut rest: Vec<Step<'a>>) -> Vec<Step<'a>> {
153    use crate::demands::Level;
154    let mut out: Vec<Step<'a>> = Vec::with_capacity(rest.len());
155    while !rest.is_empty() {
156        let ahead = rest.len().min(LOOK_AHEAD);
157        let ready = |s: &Step, rest: &[Step], out: &[Step]| s.planned.waits_for.iter().all(|w| out.iter().any(|o| &o.task.uid == w) || !rest.iter().any(|r| &r.task.uid == w));
158        let first = (0..ahead).find(|&k| ready(&rest[k], &rest, &out)).unwrap_or(0);
159        let pick = match out.last() {
160            Some(prev) => {
161                let heavy_twice = |s: &Step| prev.level == Level::Heavy && s.level == Level::Heavy;
162                let same_cost = |s: &Step| prev.dominant.is_some() && s.dominant == prev.dominant;
163                if heavy_twice(&rest[first]) || same_cost(&rest[first]) {
164                    (0..ahead)
165                        .find(|&k| ready(&rest[k], &rest, &out) && !heavy_twice(&rest[k]) && !same_cost(&rest[k]))
166                        .or_else(|| (0..ahead).find(|&k| ready(&rest[k], &rest, &out) && !heavy_twice(&rest[k])))
167                        .unwrap_or(first)
168                } else {
169                    first
170                }
171            }
172            None => first,
173        };
174        out.push(rest.remove(pick));
175    }
176    out
177}
178
179/// The first stretch of `free` from `from` on, `least` long at least, `most` at most.
180fn first_gap(free: &[(i64, i64)], from: i64, least: i64, most: i64) -> Option<(i64, i64)> {
181    free.iter().map(|&(a, b)| (rounded(a.max(from)), b)).find(|(a, b)| b - a >= least).map(|(a, b)| (a, b.min(a + most)))
182}
183
184/// The next five minutes: blocks start on readable times.
185fn rounded(seconds: i64) -> i64 {
186    (seconds + 299).div_euclid(300) * 300
187}
188
189/// Today laid out: `events` of today, and the plan's steps for today in its
190/// order, from now into the hours of their kind (`settings`), around the
191/// events and what is laid already.
192pub fn day(now: &Zoned, events: &[Occurrence], plan: &Plan, tasks: &[Task], settings: &Settings) -> DayView {
193    let zone = now.time_zone().clone();
194    let today = now.date();
195    let stamp = now.timestamp().as_second();
196    let midnight = today.to_zoned(zone.clone()).map_or(stamp, |z| z.timestamp().as_second());
197    // The next midnight on the clock: the day of a change of hour lasts 23 or 25 hours.
198    let next_midnight = today.tomorrow().ok().and_then(|d| d.to_zoned(zone.clone()).ok()).map_or(midnight + 86_400, |z| z.timestamp().as_second());
199    let at = |hour: i8| jiff::civil::Time::new(hour, 0, 0, 0).ok().and_then(|t| today.to_datetime(t).to_zoned(zone.clone()).ok()).map_or(midnight + i64::from(hour) * 3600, |z| z.timestamp().as_second());
200    // The day's hours, the end of work moved by free time included (light steps only there).
201    let hours = settings.hours_on(today, &zone);
202    let moved = settings.extension_on(today, &zone);
203    let (opening, closing) = match (hours.first(), hours.last()) {
204        (Some(first), Some(last)) => (first.0, last.1),
205        _ => (at(9), at(17)),
206    };
207    // Today, and the end of hours that run past midnight: what lasts longer is cut there.
208    let day_end = next_midnight.max(closing);
209    let mut view = DayView { now: stamp, hours: hours.iter().map(|&(start, end, open)| Stretch { start, end, kind: open.id() }).collect(), ..DayView::default() };
210    // A task's time block is the task's time, laid as the task (`blocks`): never an event of its own here.
211    let own: Vec<Occurrence> = events.iter().filter(|e| e.task.is_empty() || !plan.items.contains_key(&e.task)).cloned().collect();
212    let events = own.as_slice();
213    // A task pinned to a block today: laid there, its margins around it, whatever else is.
214    let block_today = |uid: &str| plan.pins.get(uid).filter(|pin| pin.end > midnight && pin.start < next_midnight);
215    let mut timed: Vec<&Occurrence> = Vec::new();
216    for event in events.iter().filter(|e| !e.cancelled && e.end > midnight && e.start < next_midnight) {
217        if event.all_day {
218            view.all_day.push(event.summary.clone());
219        } else {
220            timed.push(event);
221        }
222    }
223    timed.sort_by_key(|e| e.start);
224    for event in &timed {
225        // An event from yesterday evening, or of several days, shows its part of today.
226        let (start, end) = (event.start.max(midnight), event.end.max(event.start + 15 * 60).min(day_end));
227        view.blocks.push(Block { start, end, kind: "event", title: event.summary.clone(), key: event.key.clone(), energy: String::new(), location: event.location.clone(), column: 0, columns: 1, part: 0, pinned: false, note: String::new(), read_only: event.read_only, recurring: event.recurring });
228        // Its margins, shown apart: getting there and back is not the event, nor a pause.
229        let margin = |from: i64, to: i64| Block { start: from.max(midnight), end: to.min(day_end), kind: "margin", title: event.summary.clone(), key: event.key.clone(), energy: String::new(), location: String::new(), column: 0, columns: 1, part: 0, pinned: false, note: String::new(), read_only: event.read_only, recurring: event.recurring };
230        for block in [margin(start - i64::from(event.margins.before) * 60, start), margin(end, end + i64::from(event.margins.after) * 60)] {
231            if block.end > block.start {
232                view.blocks.push(block);
233            }
234        }
235    }
236    // What was done today stays in view, where it ended: its length, a quarter of an hour at least, an hour at most.
237    for task in tasks.iter().filter(|t| t.status == crate::tasks::Status::Completed) {
238        let Some(done) = task.completed.filter(|&at| at >= midnight && at < next_midnight) else { continue };
239        let length = i64::from(task.estimate.clamp(15, 60)) * 60;
240        // Done in the block that held its work: there, the record of when it was done.
241        let (start, end) = match settings.records.get(&task.uid) {
242            Some(&(from, to)) => (from.max(midnight), to.min(day_end).max(from.max(midnight) + 15 * 60)),
243            None => ((done - length).max(midnight), done.max(midnight + 15 * 60)),
244        };
245        view.blocks.push(Block { start, end, kind: "done", title: task.title.clone(), key: task.uid.clone(), energy: String::new(), location: String::new(), column: 0, columns: 1, part: 0, pinned: false, note: String::new(), read_only: false, recurring: false });
246    }
247    // Meals and naps, kept free (`needs`), as today has them (its own changes):
248    // shown, a step never laid over them. The night is kept free too, but not
249    // drawn: the day would run from midnight.
250    for kept in settings.needs.kept_with(today, &zone, &settings.needs_days, &|key: &str| settings.shifts.get(key).copied().unwrap_or(0)).into_iter().filter(|k| k.kind != "sleep") {
251        let (start, end) = (kept.start.max(midnight), kept.end.min(day_end));
252        if end > start {
253            view.blocks.push(Block { start, end, kind: kept.kind, title: kept.name.clone(), key: kept.key.clone(), energy: String::new(), location: String::new(), column: 0, columns: 1, part: 0, pinned: false, note: String::new(), read_only: false, recurring: false });
254        }
255    }
256    // Held: the events with a pause around each, what is kept free, what passed, and each step laid, with its pause.
257    let pause = i64::from(settings.pause) * 60;
258    let capacity = &settings.capacity;
259    let mut busy: Vec<(i64, i64)> = crate::plan::event_spans(events, settings.pause);
260    busy.extend(settings.kept_on(today, &zone, today));
261    busy.push((i64::MIN, rounded(stamp)));
262    let start_at = rounded(stamp);
263    // Today's steps, in the plan's order.
264    let steps: Vec<Step> = plan
265        .order
266        .iter()
267        .filter_map(|uid| Some((tasks.iter().find(|t| &t.uid == uid)?, plan.items.get(uid)?)))
268        .filter(|(task, planned)| (block_today(&task.uid).is_some() || (!planned.optional && planned.open_steps == 0)) && planned.start == Some(today) && planned.on_start > 0)
269        .map(|(task, planned)| {
270            let rates = capacity.rates_of(task);
271            Step { task, planned, level: rates.level(), dominant: rates.dominant() }
272        })
273        .collect();
274    let block_of = |task: &Task, level: crate::demands::Level, start: i64, end: i64, kind: &'static str, part: u32, pinned: bool| Block {
275        start,
276        end,
277        kind,
278        title: task.title.clone(),
279        key: task.uid.clone(),
280        energy: level.energy().to_string(),
281        location: task.location.clone(),
282        column: 0,
283        columns: 1,
284        part,
285        pinned,
286        note: String::new(),
287        read_only: false,
288        recurring: false,
289    };
290    // A step laid: its margins at both ends, shown apart (getting there and back is not the step, nor a pause).
291    let lay = |view: &mut DayView, step: &Step, from: i64, to: i64, part: u32, pinned: bool| {
292        let (before, after) = (i64::from(step.task.margins.before) * 60, i64::from(step.task.margins.after) * 60);
293        let (inner_from, inner_to) = if to - from > before + after { (from + before, to - after) } else { (from, to) };
294        if inner_from > from {
295            view.blocks.push(block_of(step.task, step.level, from, inner_from, "margin", part, pinned));
296        }
297        view.blocks.push(block_of(step.task, step.level, inner_from, inner_to, "task", part, pinned));
298        if to > inner_to {
299            view.blocks.push(block_of(step.task, step.level, inner_to, to, "margin", part, pinned));
300        }
301    };
302    // The costliest block of the day (task or event, by what it weighs): the gain slot comes after it.
303    let gain_on = capacity.gain_slot > 0;
304    let gain_length = i64::from(capacity.gain_slot) * 60;
305    let event_weight = |e: &Occurrence| crate::capacity::Rates::of_event(e).load(u32::try_from((e.end - e.start) / 60).unwrap_or(0))[crate::capacity::TOTAL];
306    let step_weight = |s: &Step| capacity.rates_of(s.task).load(s.planned.on_start)[crate::capacity::TOTAL];
307    let costliest_event = timed.iter().filter(|e| e.end > stamp).map(|e| (event_weight(e), e)).fold(None, |best: Option<(f32, &&Occurrence)>, (w, e)| if best.is_some_and(|(b, _)| b >= w) { best } else { Some((w, e)) });
308    let costliest_step = steps.iter().map(|s| (step_weight(s), s.task.uid.as_str())).fold(None, |best: Option<(f32, &str)>, (w, u)| if best.is_some_and(|(b, _)| b >= w) { best } else { Some((w, u)) });
309    let after_step = match (costliest_event, costliest_step) {
310        (Some((we, _)), Some((ws, uid))) if ws > we => Some(uid),
311        (None, Some((_, uid))) => Some(uid),
312        _ => None,
313    };
314    let mut gains: Vec<(i64, i64, &'static str)> = Vec::new();
315    // Free time of the day, whatever its hours, from `from` on.
316    let anywhere = |busy: &[(i64, i64)]| -> Vec<(i64, i64)> { crate::plan::less(&[(midnight, day_end, Area::ALL)], busy).into_iter().map(|(a, b, _)| (a, b)).collect() };
317    if gain_on
318        && after_step.is_none()
319        && let Some((_, event)) = costliest_event
320    {
321        let end = event.end + i64::from(event.margins.after) * 60 + pause;
322        if let Some((from, to)) = first_gap(&anywhere(&busy), end, GAIN_LEAST * 60, gain_length).filter(|(from, _)| *from < end + 2 * 3600) {
323            gains.push((from, to, "gain:after"));
324            busy.push((from, to + pause));
325        }
326    }
327    let mut laid: BTreeMap<&str, i64> = BTreeMap::new();
328    let mut heavy_laid: Vec<(i64, i64)> = Vec::new();
329    // Pinned to a block today, or given a time today by a drag before blocks (`Task::at`): laid there first,
330    // its margins before and after, whatever else is.
331    let (pinned, others): (Vec<Step>, Vec<Step>) = steps.into_iter().partition(|s| block_today(&s.task.uid).is_some() || s.task.at_on(today, &zone).is_some());
332    for step in &pinned {
333        let (from, to) = match block_today(&step.task.uid) {
334            // Its block, as the calendar has it: under way, it shows from its start.
335            Some(pin) => (pin.span().0.max(midnight), pin.span().1.min(day_end)),
336            None => {
337                let Some(at) = step.task.at_on(today, &zone) else { continue };
338                let length = i64::from(step.planned.on_start.max(step.planned.laid)) * 60;
339                let (before, after) = (i64::from(step.task.margins.before) * 60, i64::from(step.task.margins.after) * 60);
340                // The step's own start is the time given; one already past is laid from now.
341                let inner = at.max(start_at + before);
342                (inner - before, inner + (length - before - after).max(5 * 60) + after)
343            }
344        };
345        lay(&mut view, step, from, to, 0, true);
346        busy.push((from - if block_today(&step.task.uid).is_some() { pause } else { 0 }, to + pause));
347        if step.level == crate::demands::Level::Heavy {
348            heavy_laid.push((from, to));
349        }
350        laid.insert(step.task.uid.as_str(), to);
351    }
352    for step in spread_out(others) {
353        let (task, planned) = (step.task, step.planned);
354        // What it waits for comes first: when that is not laid today, neither is it.
355        if planned.waits_for.iter().any(|w| !laid.contains_key(w.as_str())) {
356            view.more += 1;
357            continue;
358        }
359        // The hours open to it, one run where they follow each other.
360        let kinds = settings.usable(task);
361        let mut runs: Vec<(i64, i64, Area)> = Vec::new();
362        for &(from, to, _) in hours.iter().filter(|h| h.2.meets(kinds)) {
363            match runs.last_mut() {
364                Some(last) if last.1 == from => last.1 = to,
365                _ => runs.push((from, to, Area::ALL)),
366            }
367        }
368        // A call to an office only while it is open: its own hours, else offices' usual ones.
369        if task.office_hours {
370            let own = crate::window::parse_ranges(&task.office_times);
371            let open: Vec<(i64, i64)> = crate::window::spans_on(if own.is_empty() { &settings.office_hours } else { &own }, today, &zone).iter().map(|(o, c)| (o.timestamp().as_second(), c.timestamp().as_second())).collect();
372            runs = runs.iter().flat_map(|&(from, to, area)| open.iter().filter(move |&&(o, c)| from.max(o) < to.min(c)).map(move |&(o, c)| (from.max(o), to.min(c), area))).collect();
373        }
374        // The end of work moved by free time: light steps only, and those there first (docs/pauses.md).
375        let light = matches!(step.level, crate::demands::Level::Light | crate::demands::Level::Rest);
376        if !light && !moved.is_empty() {
377            runs = crate::plan::less(&runs, &moved);
378        }
379        if runs.is_empty() {
380            view.more += 1;
381            continue;
382        }
383        // After what it waits for, laid today.
384        let after = planned.waits_for.iter().filter_map(|w| laid.get(w.as_str())).max().map_or(i64::MIN, |end| end + pause);
385        // A heavy step is followed by a light one or a quarter of an hour's break, and two heavy ones never touch.
386        let mut held = busy.clone();
387        if step.level != crate::demands::Level::Light && step.level != crate::demands::Level::Rest {
388            held.extend(heavy_laid.iter().map(|&(_, end)| (end, end + BREAK_AFTER_HEAVY * 60)));
389        }
390        if step.level == crate::demands::Level::Heavy {
391            held.extend(heavy_laid.iter().map(|&(start, _)| (start - BREAK_AFTER_HEAVY * 60, start)));
392        }
393        let mut free: Vec<(i64, i64)> = crate::plan::less(&runs, &held).into_iter().map(|(from, to, _)| (rounded(from.max(after)), to)).filter(|(from, to)| to > from).collect();
394        if light && !moved.is_empty() {
395            let inside: Vec<(i64, i64)> = free.iter().flat_map(|&(a, b)| moved.iter().filter_map(move |&(m, n)| (a.max(m) < b.min(n)).then_some((a.max(m), b.min(n))))).collect();
396            let outside = crate::plan::less(&free.iter().map(|&(a, b)| (a, b, Area::ALL)).collect::<Vec<_>>(), &moved).into_iter().map(|(a, b, _)| (a, b));
397            free = inside.into_iter().chain(outside).collect();
398        }
399        let length = i64::from(planned.on_start) * 60;
400        // A step up to an hour in one go, in the first gap that holds it; a longer
401        // one (or one the plan already cut) from the first gap on, in parts of a
402        // quarter of an hour at least.
403        // Never cut when it has margins: going there twice is not the same step.
404        let cut = task.margins.is_empty() && (planned.laid > WHOLE || planned.on_start < planned.laid.max(SHORTEST));
405        let pieces: Vec<(i64, i64)> = if cut {
406            let (mut pieces, mut owed, shortest) = (Vec::new(), length, i64::from(SHORTEST) * 60);
407            for &(from, to) in &free {
408                let mut take = owed.min(to - from);
409                // As the plan cuts: never less than a quarter of an hour left for later
410                // (40 minutes in gaps of 35 and 20 go 25 + 15, not 35 + 5); else not this gap.
411                if take < owed && owed - take < shortest {
412                    take = owed - shortest;
413                }
414                if take >= owed.min(shortest) {
415                    pieces.push((from, from + take));
416                    owed -= take;
417                }
418                if owed == 0 {
419                    break;
420                }
421            }
422            if owed > 0 { Vec::new() } else { pieces }
423        } else {
424            free.iter().find(|(from, to)| to - from >= length).map(|&(from, _)| vec![(from, from + length)]).unwrap_or_default()
425        };
426        if pieces.is_empty() {
427            view.more += 1;
428            continue;
429        }
430        let count = pieces.len();
431        for (k, &(from, to)) in pieces.iter().enumerate() {
432            lay(&mut view, &step, from, to, if count > 1 { k as u32 + 1 } else { 0 }, false);
433            busy.push((from, to + pause));
434            if step.level == crate::demands::Level::Heavy {
435                heavy_laid.push((from, to));
436            }
437        }
438        let end = pieces.last().map_or(0, |p| p.1);
439        laid.insert(task.uid.as_str(), end);
440        // Right after the day's costliest step: time for you, within its hours when they hold it.
441        if gain_on && after_step == Some(task.uid.as_str()) {
442            let in_hours: Vec<(i64, i64)> = crate::plan::less(&runs, &busy).into_iter().map(|(a, b, _)| (a, b)).collect();
443            let near = |gap: &(i64, i64)| gap.0 < end + 2 * 3600;
444            let slot = first_gap(&in_hours, end + pause, GAIN_LEAST * 60, gain_length).filter(near).or_else(|| first_gap(&anywhere(&busy), end + pause, GAIN_LEAST * 60, gain_length).filter(near));
445            if let Some((from, to)) = slot {
446                gains.push((from, to, "gain:after"));
447                busy.push((from, to + pause));
448            }
449        }
450    }
451    // Free time kept for steps running long, after the last one laid, within the
452    // day's hours: as the plan kept it for the steps it laid today (TE16).
453    let last_step = view.blocks.iter().filter(|b| b.kind == "task" || (b.kind == "margin" && laid.contains_key(b.key.as_str()))).map(|b| b.end).max();
454    if let (Some(last), Some(&slack)) = (last_step, plan.slack.get(&today)) {
455        let wanted = i64::from(slack) * 60;
456        let in_hours: Vec<(i64, i64)> = crate::plan::less(&hours, &busy).into_iter().map(|(a, b, _)| (a, b)).collect();
457        if let Some((from, to)) = first_gap(&in_hours, last + pause, SLACK_SHOWN * 60, wanted).filter(|_| wanted >= SLACK_SHOWN * 60) {
458            view.blocks.push(Block { start: from, end: to, kind: "slack", title: String::new(), key: "slack".into(), energy: String::new(), location: String::new(), column: 0, columns: 1, part: 0, pinned: false, note: String::new(), read_only: false, recurring: false });
459            busy.push((from, to));
460        }
461    }
462    // In the evening, once today's hours are over, before the night: time for you (G15).
463    if gain_on {
464        let evening = if hours.is_empty() { at(18) } else { closing };
465        let night = settings.needs.kept_with(today, &zone, &settings.needs_days, &|key: &str| settings.shifts.get(key).copied().unwrap_or(0)).into_iter().filter(|k| k.kind == "sleep" && k.start > evening).map(|k| k.start).min().unwrap_or(at(23));
466        if let Some((from, to)) = first_gap(&anywhere(&busy), evening.max(start_at), GAIN_LEAST * 60, gain_length).filter(|(_, to)| *to <= night) {
467            gains.push((from, to, "gain:evening"));
468        }
469    }
470    for (from, to, key) in gains {
471        view.blocks.push(Block { start: from, end: to, kind: "gain", title: String::new(), key: key.into(), energy: String::new(), location: String::new(), column: 0, columns: 1, part: 0, pinned: false, note: String::new(), read_only: false, recurring: false });
472    }
473    view.blocks.sort_by_key(|b| b.start);
474    // Two events at once: side by side, never one over the other.
475    let spans: Vec<(i64, i64)> = view.blocks.iter().map(|b| (b.start, b.end)).collect();
476    for (block, (column, columns)) in view.blocks.iter_mut().zip(side_by_side(&spans)) {
477        block.column = column;
478        block.columns = columns;
479    }
480    // From the first hour to the last, and now, within today.
481    view.from = view.blocks.iter().map(|b| b.start).min().unwrap_or(opening).min(opening).min(stamp.max(midnight));
482    view.to = view.blocks.iter().map(|b| b.end).max().unwrap_or(closing).max(closing).max(stamp.min(day_end));
483    // Whole hours at each end, on the clock: in a zone half an hour off UTC too.
484    view.from = whole_hour(view.from, &zone, false);
485    view.to = whole_hour(view.to, &zone, true);
486    view
487}
488
489/// `seconds` down, or up, to a whole hour as the clock in `zone` shows it.
490fn whole_hour(seconds: i64, zone: &jiff::tz::TimeZone, up: bool) -> i64 {
491    let offset = jiff::Timestamp::from_second(seconds).map_or(0, |t| i64::from(zone.to_offset(t).seconds()));
492    let local = seconds + offset;
493    let down = local - local.rem_euclid(3600);
494    (if up && down < local { down + 3600 } else { down }) - offset
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::areas::TaskAreas;
501    use crate::plan::plan;
502    use crate::window::AdminWindow;
503    use jiff::tz::TimeZone;
504    use std::collections::{BTreeMap, BTreeSet};
505
506    fn at(text: &str) -> Zoned {
507        text.parse::<jiff::civil::DateTime>().unwrap().to_zoned(TimeZone::get("Europe/Paris").unwrap()).unwrap()
508    }
509
510    fn window(day: &str, start: &str, end: &str, kind: &str) -> AdminWindow {
511        AdminWindow { day: day.into(), start: start.into(), end: Some(end.into()), minutes: 0, kind: Some(kind.into()) }
512    }
513
514    /// Minutes from midnight of each block, with its title.
515    fn laid(view: &DayView) -> Vec<(&str, i64)> {
516        let midnight = at("2026-10-05T00:00").timestamp().as_second();
517        view.blocks.iter().map(|b| (b.title.as_str(), (b.start - midnight) / 60)).collect()
518    }
519
520    fn task(uid: &str, estimate: u32, category: &str) -> Task {
521        Task { uid: uid.into(), title: uid.into(), estimate, categories: if category.is_empty() { vec![] } else { vec![category.into()] }, ..Task::default() }
522    }
523
524    fn laid_out(now: &Zoned, windows: &[AdminWindow], events: &[Occurrence], tasks: &[Task]) -> DayView {
525        let settings = Settings::of_hours(windows, TaskAreas::usual()).with_events(now, events);
526        let made = plan(tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
527        day(now, events, &made, tasks, &settings)
528    }
529
530    #[test]
531    fn steps_go_around_events() {
532        let now = at("2026-10-05T09:58");
533        let hours = vec![window("monday", "09:00", "12:00", "work")];
534        let tasks = vec![task("form", 30, ""), task("call", 45, ""), task("long", 120, "")];
535        let call = at("2026-10-05T10:30").timestamp().as_second();
536        let meeting = Occurrence { key: "m.ics".into(), uid: "m".into(), summary: "Meeting".into(), start: call, end: call + 1800, ..Occurrence::default() };
537        let view = laid_out(&now, &hours, &[meeting], &tasks);
538        // 10:00–10:25 before the meeting (a pause before it), 11:05–12:00 after it: the form after
539        // the meeting; the call does not fit whole any more today; the long one in two parts, 25 + 15.
540        assert_eq!(laid(&view), vec![("long", 600), ("Meeting", 630), ("form", 665), ("long", 700)], "{:?}", laid(&view));
541        assert_eq!(view.blocks.iter().filter(|b| b.part > 0).count(), 2);
542        assert_eq!(view.more, 0, "the call waits for another day in the plan");
543        assert_eq!(view.from, at("2026-10-05T09:00").timestamp().as_second());
544        assert_eq!(view.to, at("2026-10-05T12:00").timestamp().as_second());
545        assert!(view.blocks.iter().all(|b| (b.column, b.columns) == (0, 1)), "nothing overlaps");
546    }
547
548    #[test]
549    fn no_step_over_a_meal() {
550        // Work 9:00–17:00 straight; lunch kept from 12:10 (getting it ready) to 13:00.
551        let now = at("2026-10-05T11:30");
552        let hours = vec![window("monday", "09:00", "17:00", "work")];
553        let needs = crate::needs::Needs { meals_on: true, ..crate::needs::Needs::default() };
554        let settings = Settings::of_hours(&hours, TaskAreas::usual()).with_needs(&needs, BTreeMap::new()).with_events(&now, &[]);
555        let tasks = vec![task("short", 20, ""), task("long", 60, "")];
556        let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
557        let view = day(&now, &[], &made, &tasks, &settings);
558        // The short one before lunch; the long one after it, never across it.
559        let steps: Vec<(&str, i64)> = laid(&view).into_iter().filter(|(t, _)| *t == "short" || *t == "long").collect();
560        assert_eq!(steps, vec![("short", 690), ("long", 780)], "{:?}", laid(&view));
561        let lunch = view.blocks.iter().find(|b| b.key == "meal:1").unwrap();
562        assert_eq!((lunch.start, lunch.end), (at("2026-10-05T12:10").timestamp().as_second(), at("2026-10-05T13:00").timestamp().as_second()));
563    }
564
565    #[test]
566    fn no_step_in_the_break() {
567        let now = at("2026-10-05T11:30");
568        let hours = vec![window("monday", "09:00", "12:00", "work"), window("monday", "14:00", "17:00", "work")];
569        let view = laid_out(&now, &hours, &[], &[task("short", 20, ""), task("long", 60, "")]);
570        // 11:30 the short one (to 11:50); the long one would run into lunch: at 14:00.
571        assert_eq!(laid(&view), vec![("short", 690), ("long", 840)], "{:?}", laid(&view));
572    }
573
574    #[test]
575    fn each_step_in_hours_of_its_kind() {
576        let now = at("2026-10-05T08:00");
577        let hours = vec![window("monday", "09:00", "12:00", "work"), window("monday", "17:00", "18:30", "admin"), window("monday", "18:30", "21:00", "leisure")];
578        let tasks = vec![task("tax", 30, "admin"), task("report", 60, "work"), task("friends", 60, "friends")];
579        let view = laid_out(&now, &hours, &[], &tasks);
580        // The tax form in admin hours, the report in work hours; leisure has no
581        // hours (an older Sioul's free time is left aside): friends take none.
582        let mut seen = laid(&view);
583        seen.sort();
584        assert_eq!(seen, vec![("report", 9 * 60), ("tax", 17 * 60)]);
585        assert_eq!(view.hours.iter().map(|h| h.kind.as_str()).collect::<Vec<_>>(), vec!["work", "admin"]);
586    }
587
588    #[test]
589    fn a_long_step_goes_on_after_lunch() {
590        let now = at("2026-10-05T10:00");
591        let hours = vec![window("monday", "09:00", "12:00", "work"), window("monday", "14:00", "17:00", "work")];
592        let view = laid_out(&now, &hours, &[], &[task("thesis", 180, "work")]);
593        assert_eq!(laid(&view), vec![("thesis", 600), ("thesis", 840)], "{:?}", laid(&view));
594        assert_eq!(view.blocks.iter().map(|b| (b.end - b.start) / 60).sum::<i64>(), 180);
595        assert_eq!(view.blocks.iter().map(|b| b.part).collect::<Vec<_>>(), vec![1, 2]);
596    }
597
598    #[test]
599    fn no_piece_under_a_quarter_of_an_hour() {
600        // Work 9:00–10:20, a visit 9:40–9:55: gaps of 35 and 20 minutes, its pauses around it. The day's weather
601        // leaves the thesis 40 minutes today: 25 + 15, never 35 + 5.
602        let now = at("2026-10-05T08:00");
603        let hours = vec![window("monday", "09:00", "10:20", "work")];
604        let visit = Occurrence { key: "v.ics".into(), summary: "Visit".into(), start: at("2026-10-05T09:40").timestamp().as_second(), end: at("2026-10-05T09:55").timestamp().as_second(), ..Occurrence::default() };
605        let mut settings = Settings::of_hours(&hours, TaskAreas::usual()).with_events(&now, std::slice::from_ref(&visit));
606        settings.today_percent = 73;
607        let tasks = vec![task("thesis", 100, "work")];
608        let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
609        assert_eq!(made.items["thesis"].on_start, 40);
610        let view = day(&now, &[visit], &made, &tasks, &settings);
611        assert_eq!(laid(&view), vec![("thesis", 540), ("Visit", 580), ("thesis", 600)], "{:?}", laid(&view));
612        let lengths: Vec<i64> = view.blocks.iter().filter(|b| b.kind == "task").map(|b| (b.end - b.start) / 60).collect();
613        assert_eq!(lengths, vec![25, 15]);
614    }
615
616    #[test]
617    fn calls_while_offices_open_and_waits_kept() {
618        // Work 9–10, admin hours 17–19: the report fills the morning; the call is not laid at 17:00, offices are closed.
619        let now = at("2026-10-05T08:00");
620        let hours = vec![window("monday", "09:00", "10:00", "work"), window("monday", "17:00", "19:00", "admin")];
621        let report = Task { due: "2026-10-05".into(), ..task("report", 60, "work") };
622        let call = Task { office_hours: true, ..task("call", 30, "") };
623        let view = laid_out(&now, &hours, &[], &[report, call]);
624        assert_eq!((laid(&view), view.more), (vec![("report", 540)], 1));
625        // A step waiting for one that does not fit today is not laid before it.
626        let hours = vec![window("monday", "09:00", "11:00", "work")];
627        let visit = Occurrence { key: "v.ics".into(), summary: "Visit".into(), start: at("2026-10-05T09:55").timestamp().as_second(), end: at("2026-10-05T10:05").timestamp().as_second(), ..Occurrence::default() };
628        let small = Task { relations: vec![crate::tasks::Relation { kind: "DEPENDS-ON".into(), uid: "big".into(), gap: 0 }], ..task("small", 15, "") };
629        let view = laid_out(&now, &hours, &[visit], &[task("big", 60, ""), small]);
630        assert_eq!((laid(&view), view.more), (vec![("Visit", 595)], 2));
631    }
632
633    #[test]
634    fn days_of_23_and_25_hours_and_of_several() {
635        // Sunday 25 October 2026 lasts 25 hours: an event at 23:30 is still today's.
636        let late = Occurrence { key: "l.ics".into(), summary: "Late".into(), start: at("2026-10-25T23:30").timestamp().as_second(), end: at("2026-10-25T23:45").timestamp().as_second(), ..Occurrence::default() };
637        assert_eq!(laid_out(&at("2026-10-25T10:00"), &[], &[late], &[]).blocks.len(), 1);
638        // Sunday 29 March 2026 lasts 23: Monday's whole-day event is not today's.
639        let monday = at("2026-03-30T00:00").timestamp().as_second();
640        let holiday = Occurrence { summary: "Holiday".into(), all_day: true, start: monday, end: monday + 86_400, ..Occurrence::default() };
641        assert!(laid_out(&at("2026-03-29T10:00"), &[], &[holiday], &[]).all_day.is_empty());
642        // A conference over three days: today's part of it, the view within today.
643        let conference = Occurrence { key: "c.ics".into(), summary: "Conference".into(), start: at("2026-10-05T09:00").timestamp().as_second(), end: at("2026-10-07T17:00").timestamp().as_second(), ..Occurrence::default() };
644        let view = laid_out(&at("2026-10-06T08:00"), &[], &[conference], &[]);
645        let midnight = at("2026-10-06T00:00").timestamp().as_second();
646        assert_eq!((view.blocks[0].start, view.blocks[0].end, view.from, view.to), (midnight, midnight + 86_400, midnight, midnight + 86_400));
647    }
648
649    #[test]
650    fn done_today_stays_in_view() {
651        let now = at("2026-10-05T15:00");
652        let done_at = at("2026-10-05T11:00").timestamp().as_second();
653        let letter = Task { status: crate::tasks::Status::Completed, completed: Some(done_at), ..task("letter", 30, "") };
654        let yesterday = Task { status: crate::tasks::Status::Completed, completed: Some(done_at - 86_400), ..task("old", 30, "") };
655        let view = laid_out(&now, &[], &[], &[letter, yesterday]);
656        let done: Vec<(&str, &str, i64)> = view.blocks.iter().map(|b| (b.kind, b.title.as_str(), (b.end - b.start) / 60)).collect();
657        assert_eq!(done, vec![("done", "letter", 30)]);
658    }
659
660    #[test]
661    fn margins_kept_free_and_shown() {
662        // The dentist 10–11, half an hour to get there, 20 minutes back; posting a
663        // parcel, 30 minutes and a quarter of an hour each way: not before, there is no room.
664        let now = at("2026-10-05T09:00");
665        let hours = vec![window("monday", "09:00", "13:00", "work")];
666        let margins = |before, after| crate::demands::Margins { before, after };
667        let dentist = Occurrence { key: "d.ics".into(), summary: "Dentist".into(), start: at("2026-10-05T10:00").timestamp().as_second(), end: at("2026-10-05T11:00").timestamp().as_second(), margins: margins(30, 20), ..Occurrence::default() };
668        let parcel = Task { margins: margins(15, 15), ..task("parcel", 30, "") };
669        let view = laid_out(&now, &hours, &[dentist], &[parcel]);
670        let pause = i64::from(crate::plan::PAUSE);
671        let kinds: Vec<(&str, &str, i64)> = view.blocks.iter().map(|b| (b.kind, b.title.as_str(), (b.start - at("2026-10-05T00:00").timestamp().as_second()) / 60)).collect();
672        assert_eq!(
673            kinds,
674            vec![("margin", "Dentist", 570), ("event", "Dentist", 600), ("margin", "Dentist", 660), ("margin", "parcel", 680 + pause), ("task", "parcel", 695 + pause), ("margin", "parcel", 725 + pause)]
675        );
676    }
677
678    fn rated(uid: &str, estimate: u32, anxiety: u8, cognitive: u8) -> Task {
679        Task { demands: crate::demands::Demands { anxiety: Some(anxiety), cognitive: Some(cognitive), ..crate::demands::Demands::default() }, ..task(uid, estimate, "") }
680    }
681
682    fn steps_of(view: &DayView) -> Vec<(&str, i64, i64)> {
683        let midnight = at("2026-10-05T00:00").timestamp().as_second();
684        view.blocks.iter().filter(|b| b.kind == "task").map(|b| (b.title.as_str(), (b.start - midnight) / 60, (b.end - midnight) / 60)).collect()
685    }
686
687    #[test]
688    fn never_two_heavy_steps_in_a_row() {
689        // Work 9–13: heavy, heavy, light, usual in the plan's order (all equal, so by title).
690        let now = at("2026-10-05T08:00");
691        let hours = vec![window("monday", "09:00", "13:00", "work")];
692        let tasks = vec![rated("a-heavy", 30, 8, 2), rated("b-heavy", 30, 2, 8), rated("c-light", 30, 2, 1), rated("d-usual", 30, 5, 2)];
693        let view = laid_out(&now, &hours, &[], &tasks);
694        let order: Vec<&str> = steps_of(&view).iter().map(|s| s.0).collect();
695        assert_eq!(order, vec!["a-heavy", "c-light", "b-heavy", "d-usual"], "{:?}", steps_of(&view));
696        // After a heavy step, a usual one waits a quarter of an hour; a light one only the pause.
697        let s = steps_of(&view);
698        assert_eq!(s[1].1 - s[0].2, 5, "light after heavy: the pause");
699        assert!(s[3].1 - s[2].2 >= BREAK_AFTER_HEAVY, "usual after heavy: a break: {s:?}");
700        assert!(view.blocks.iter().filter(|b| b.kind == "task" && b.title.ends_with("heavy")).all(|b| b.energy == "heavy"));
701    }
702
703    #[test]
704    fn only_heavy_steps_get_breaks_between() {
705        let now = at("2026-10-05T08:00");
706        let hours = vec![window("monday", "09:00", "12:00", "work")];
707        let view = laid_out(&now, &hours, &[], &[rated("one", 30, 8, 0), rated("two", 30, 9, 0)]);
708        let s = steps_of(&view);
709        assert_eq!(s.len(), 2);
710        assert!(s[1].1 - s[0].2 >= BREAK_AFTER_HEAVY, "{s:?}");
711    }
712
713    #[test]
714    fn kinds_of_cost_alternate() {
715        // Two anxious calls and a cognitive form: the form between them.
716        let now = at("2026-10-05T08:00");
717        let hours = vec![window("monday", "09:00", "12:00", "work")];
718        let tasks = vec![rated("a-call", 20, 5, 1), rated("b-call", 20, 6, 1), rated("c-form", 20, 1, 5)];
719        let view = laid_out(&now, &hours, &[], &tasks);
720        let order: Vec<&str> = steps_of(&view).iter().map(|s| s.0).collect();
721        assert_eq!(order, vec!["a-call", "c-form", "b-call"]);
722    }
723
724    #[test]
725    fn a_task_with_margins_is_never_cut() {
726        // Work 9–10 and 11–13; posting a parcel: 50 minutes and a quarter of an hour each way.
727        let now = at("2026-10-05T08:00");
728        let hours = vec![window("monday", "09:00", "10:00", "work"), window("monday", "11:00", "13:00", "work")];
729        let parcel = Task { margins: crate::demands::Margins { before: 15, after: 15 }, ..task("parcel", 50, "") };
730        let settings = Settings::of_hours(&hours, TaskAreas::usual()).with_events(&now, &[]);
731        let made = plan(std::slice::from_ref(&parcel), now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
732        assert_eq!(made.items["parcel"].on_start, 80, "the plan lays it whole");
733        let view = day(&now, &[], &made, &[parcel], &settings);
734        let blocks: Vec<(&str, i64)> = view.blocks.iter().map(|b| (b.kind, (b.start - at("2026-10-05T00:00").timestamp().as_second()) / 60)).collect();
735        // Not in the first hour (it would be cut): whole at 11:00, margins at both ends.
736        assert_eq!(blocks, vec![("margin", 660), ("task", 675), ("margin", 725)]);
737        // Longer than any day: a day of its own, still whole.
738        let trip = Task { margins: crate::demands::Margins { before: 60, after: 60 }, ..task("trip", 240, "") };
739        let made = plan(std::slice::from_ref(&trip), now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
740        assert_eq!((made.items["trip"].start, made.items["trip"].finish), (Some(now.date()), Some(now.date())));
741    }
742
743    #[test]
744    fn a_step_given_a_time_keeps_it() {
745        // Dragged to 15:00, outside the morning's hours, over nothing: laid there, the rest around it.
746        let now = at("2026-10-05T08:00");
747        let hours = vec![window("monday", "09:00", "12:00", "work")];
748        let mut held = task("held", 30, "");
749        held.at = "2026-10-05T15:00".into();
750        let tasks = vec![held, task("free", 30, "")];
751        let view = laid_out(&now, &hours, &[], &tasks);
752        let pinned = view.blocks.iter().find(|b| b.title == "held").unwrap();
753        assert!(pinned.pinned);
754        assert_eq!((pinned.start - at("2026-10-05T00:00").timestamp().as_second()) / 60, 900);
755        assert!(view.blocks.iter().any(|b| b.title == "free" && !b.pinned));
756        // Yesterday's time: nothing pinned, the plan places it again.
757        let mut old = task("old", 30, "");
758        old.at = "2026-10-04T15:00".into();
759        let view = laid_out(&now, &hours, &[], &[old]);
760        assert!(view.blocks.iter().all(|b| !b.pinned));
761    }
762
763    /// A task's time block, as the calendar has it (an event naming its task).
764    fn block_of(task: &str, from: &str, to: &str) -> Occurrence {
765        Occurrence { key: format!("{task}-block.ics"), uid: format!("{task}-block"), summary: task.into(), start: at(from).timestamp().as_second(), end: at(to).timestamp().as_second(), task: task.into(), ..Occurrence::default() }
766    }
767
768    /// The day as the window lays it: the blocks sorted out of the events, then the plan, then the day.
769    fn with_blocks(now: &Zoned, hours: &[AdminWindow], events: &[Occurrence], tasks: &[Task]) -> DayView {
770        let blocks = crate::blocks::Blocks::of(events, tasks, now.timestamp().as_second(), now.time_zone());
771        let own = crate::blocks::without_blocks(events.to_vec(), tasks);
772        let settings = Settings::of_hours(hours, TaskAreas::usual()).with_pins(blocks).with_events(now, &own);
773        let made = plan(tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
774        // The events as read, blocks among them: the day sets them apart itself too.
775        day(now, events, &made, tasks, &settings)
776    }
777
778    #[test]
779    fn a_task_pinned_to_its_block_is_laid_there() {
780        // Work 9–12; "bank" pinned 10:00–10:45 by its block; "free", an hour and a half, placed by the plan.
781        let now = at("2026-10-05T08:00");
782        let hours = vec![window("monday", "09:00", "12:00", "work")];
783        let tasks = vec![task("bank", 30, ""), task("free", 90, "")];
784        let view = with_blocks(&now, &hours, &[block_of("bank", "2026-10-05T10:00", "2026-10-05T10:45")], &tasks);
785        // The task in its block, for the block's 45 minutes, held; never an event of its own.
786        assert!(view.blocks.iter().all(|b| b.kind != "event"), "{:?}", laid(&view));
787        let bank: Vec<&Block> = view.blocks.iter().filter(|b| b.title == "bank").collect();
788        assert_eq!(bank.len(), 1);
789        assert_eq!((bank[0].kind, bank[0].pinned, bank[0].start, (bank[0].end - bank[0].start) / 60), ("task", true, at("2026-10-05T10:00").timestamp().as_second(), 45));
790        // The rest of the day goes around it, a pause on each side: 9:00–9:55, then from 10:50.
791        let free: Vec<(i64, i64)> = steps_of(&view).into_iter().filter(|s| s.0 == "free").map(|s| (s.1, s.2)).collect();
792        assert_eq!(free, vec![(540, 595), (650, 685)]);
793    }
794
795    #[test]
796    fn a_past_block_leaves_its_task_to_the_plan() {
797        // 11:00: this morning's block, 10:00–10:45, is over and the task not done: laid again from now, nothing said.
798        let now = at("2026-10-05T11:00");
799        let hours = vec![window("monday", "09:00", "12:00", "work")];
800        let tasks = vec![task("bank", 30, "")];
801        let view = with_blocks(&now, &hours, &[block_of("bank", "2026-10-05T10:00", "2026-10-05T10:45")], &tasks);
802        let shown: Vec<(&str, &str, i64, bool)> = view.blocks.iter().map(|b| (b.kind, b.title.as_str(), (b.start - at("2026-10-05T00:00").timestamp().as_second()) / 60, b.pinned)).collect();
803        assert_eq!(shown, vec![("task", "bank", 660, false)]);
804        // Done in its block: shown done there, the record of when the work was done.
805        let done = Task { status: crate::tasks::Status::Completed, completed: Some(at("2026-10-05T10:40").timestamp().as_second()), ..task("bank", 30, "") };
806        let view = with_blocks(&now, &hours, &[block_of("bank", "2026-10-05T10:00", "2026-10-05T10:45")], &[done]);
807        let shown: Vec<(&str, i64, i64)> = view.blocks.iter().map(|b| (b.kind, (b.start - at("2026-10-05T00:00").timestamp().as_second()) / 60, (b.end - b.start) / 60)).collect();
808        assert_eq!(shown, vec![("done", 600, 45)]);
809    }
810
811    #[test]
812    fn gain_slots_after_the_costliest_block_and_in_the_evening() {
813        // Work 9–13; a heavy step and two light ones; the night from 22:30.
814        let now = at("2026-10-05T08:00");
815        let hours = vec![window("monday", "09:00", "13:00", "work")];
816        let needs = crate::needs::Needs::default();
817        let mut settings = Settings::of_hours(&hours, TaskAreas::usual()).with_needs(&needs, BTreeMap::new()).with_events(&now, &[]);
818        settings.capacity.gain_slot = 30;
819        let tasks = vec![rated("a-light", 20, 1, 1), rated("b-heavy", 60, 8, 2), rated("c-light", 20, 2, 1)];
820        let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
821        let view = day(&now, &[], &made, &tasks, &settings);
822        let midnight = at("2026-10-05T00:00").timestamp().as_second();
823        let gains: Vec<(&str, i64, i64)> = view.blocks.iter().filter(|b| b.kind == "gain").map(|b| (b.key.as_str(), (b.start - midnight) / 60, (b.end - b.start) / 60)).collect();
824        let heavy = view.blocks.iter().find(|b| b.title == "b-heavy").unwrap();
825        assert_eq!(gains.len(), 2, "{gains:?}");
826        assert_eq!(gains[0].0, "gain:after");
827        assert_eq!((gains[0].1, gains[0].2), ((heavy.end - midnight) / 60 + 5, 30), "right after the heavy step, its pause between");
828        assert_eq!((gains[1].0, gains[1].1, gains[1].2), ("gain:evening", 13 * 60, 30), "once the day's hours are over");
829        // Off: none.
830        settings.capacity.gain_slot = 0;
831        let view = day(&now, &[], &made, &tasks, &settings);
832        assert!(view.blocks.iter().all(|b| b.kind != "gain"));
833    }
834
835    #[test]
836    fn free_time_kept_after_the_last_step() {
837        let now = at("2026-10-05T08:00");
838        let hours = vec![window("monday", "09:00", "17:00", "work")];
839        let mut settings = Settings::of_hours(&hours, TaskAreas::usual()).with_events(&now, &[]);
840        settings.capacity.spread = Some(crate::capacity::Spread::of(0.5));
841        let tasks: Vec<Task> = (0..5).map(|k| task(&format!("t{k}"), 30, "")).collect();
842        let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
843        let slack = made.slack.get(&now.date()).copied().unwrap_or(0);
844        assert!(slack > 0, "{:?}", made.slack);
845        let view = day(&now, &[], &made, &tasks, &settings);
846        let kept = view.blocks.iter().find(|b| b.kind == "slack").unwrap();
847        let last = view.blocks.iter().filter(|b| b.kind == "task").map(|b| b.end).max().unwrap();
848        assert_eq!((kept.start, (kept.end - kept.start) / 60), (last + 5 * 60, i64::from(slack)));
849        // The same day, the same steps: the same free time.
850        assert_eq!(plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new()).slack, made.slack);
851    }
852
853    /// What `capacity::gather` gives with no history yet: the prior's spread, widened for thin data.
854    fn thin_data(settings: &mut Settings) {
855        let ratios = crate::capacity::ratios(&[], at("2026-10-06T00:00").date());
856        settings.capacity.spread = Some(crate::capacity::Spread::of(ratios.sigma));
857        settings.capacity.today_sigma = ratios.today_sigma;
858        settings.capacity.gain_slot = crate::capacity::GAIN_SLOT_MINUTES;
859    }
860
861    #[test]
862    fn a_started_long_step_keeps_part_of_today() {
863        // Tuesday 6 October, 14:38: work until 18:00. A sketch of about four hours, started, and a
864        // call of 17 minutes; no history (the widest free time). The sketch is the next step: it
865        // keeps part of today, and the free time kept stays within a third of the day's room.
866        let now = at("2026-10-06T14:38");
867        let hours = vec![window("tuesday", "09:00", "18:00", "work")];
868        let mut settings = Settings::of_hours(&hours, TaskAreas::usual()).with_events(&now, &[]);
869        thin_data(&mut settings);
870        let sketch = Task { status: crate::tasks::Status::InProcess, ..task("Esquisser deux mises en page", 240, "") };
871        let call = task("Appeler la caisse santé", 17, "");
872        let tasks = vec![sketch, call];
873        let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
874        assert_eq!(made.next.as_deref(), Some("Esquisser deux mises en page"));
875        let started = &made.items["Esquisser deux mises en page"];
876        assert_eq!(started.start, Some(now.date()), "{started:?}");
877        assert!(started.on_start >= crate::plan::SHORTEST, "{started:?}");
878        let room = settings.room_on(now.date()).total();
879        let slack = made.slack.get(&now.date()).copied().unwrap_or(0);
880        assert!(slack as f32 <= crate::capacity::SLACK_SHARE * room as f32, "{slack} of {room}");
881        // Now and the day agree: the day lays the started step today, and its free time within the hours.
882        let view = day(&now, &[], &made, &tasks, &settings);
883        assert!(view.blocks.iter().any(|b| b.kind == "task" && b.title == "Esquisser deux mises en page"), "{:?}", view.blocks);
884        let closing = at("2026-10-06T18:00").timestamp().as_second();
885        for kept in view.blocks.iter().filter(|b| b.kind == "slack") {
886            assert!(kept.end <= closing && (kept.end - kept.start) / 60 <= i64::from(slack), "{kept:?}");
887        }
888    }
889
890    #[test]
891    fn a_day_of_short_steps_keeps_some_free_time() {
892        let now = at("2026-10-05T08:00");
893        let hours = vec![window("monday", "09:00", "17:00", "work")];
894        let mut settings = Settings::of_hours(&hours, TaskAreas::usual()).with_events(&now, &[]);
895        thin_data(&mut settings);
896        let tasks: Vec<Task> = (0..4).map(|k| task(&format!("short-{k}"), 15, "")).collect();
897        let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
898        assert!(tasks.iter().all(|t| made.items[&t.uid].start == Some(now.date())));
899        let slack = made.slack.get(&now.date()).copied().unwrap_or(0);
900        assert!(slack > 0 && slack as f32 <= crate::capacity::SLACK_SHARE * 480.0, "{slack}");
901        let view = day(&now, &[], &made, &tasks, &settings);
902        let kept = view.blocks.iter().find(|b| b.kind == "slack").unwrap();
903        let last = view.blocks.iter().filter(|b| b.kind == "task").map(|b| b.end).max().unwrap();
904        assert!(kept.start >= last && kept.end <= at("2026-10-05T17:00").timestamp().as_second());
905        assert_eq!((kept.end - kept.start) / 60, i64::from(slack));
906    }
907
908    #[test]
909    fn free_time_never_empties_a_day() {
910        // Twenty half hours, no history: the day keeps at most a third free, and still holds most of the rest.
911        let now = at("2026-10-05T08:00");
912        let hours = vec![window("monday", "09:00", "17:00", "work")];
913        let mut settings = Settings::of_hours(&hours, TaskAreas::usual()).with_events(&now, &[]);
914        thin_data(&mut settings);
915        let tasks: Vec<Task> = (0..20).map(|k| task(&format!("t{k:02}"), 30, "")).collect();
916        let made = plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
917        let laid: u32 = made.days[&now.date()].iter().map(|(_, m)| m).sum();
918        let slack = made.slack.get(&now.date()).copied().unwrap_or(0);
919        assert!(slack as f32 <= crate::capacity::SLACK_SHARE * 480.0 + 1.0, "{slack}");
920        assert!(laid >= 240, "half the day at least is laid: {laid}");
921    }
922
923    #[test]
924    fn overlapping_things_side_by_side() {
925        // 9–10 and 9:30–11 overlap; 10:30–11 fits under the first; 12–13 alone.
926        let spans = [(540, 600), (570, 660), (630, 660), (720, 780)];
927        assert_eq!(side_by_side(&spans), vec![(0, 2), (1, 2), (0, 2), (0, 1)]);
928        // Touching is not overlapping.
929        assert_eq!(side_by_side(&[(540, 600), (600, 660)]), vec![(0, 1), (0, 1)]);
930    }
931}