1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct Block {
34 pub start: i64,
36 pub end: i64,
37 pub kind: &'static str,
40 pub title: String,
41 pub key: String,
43 pub energy: String,
45 pub location: String,
46 pub column: u32,
48 pub columns: u32,
49 pub part: u32,
51 pub pinned: bool,
53 pub note: String,
55 pub read_only: bool,
57 pub recurring: bool,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct Stretch {
65 pub start: i64,
66 pub end: i64,
67 pub kind: String,
68}
69
70pub 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
115pub struct DayView {
116 pub from: i64,
117 pub to: i64,
118 pub now: i64,
119 pub hours: Vec<Stretch>,
121 pub blocks: Vec<Block>,
122 pub all_day: Vec<String>,
124 pub more: usize,
126 pub said: Vec<String>,
128}
129
130pub const BREAK_AFTER_HEAVY: i64 = 15;
132pub const LOOK_AHEAD: usize = 3;
135pub const GAIN_LEAST: i64 = 15;
137pub const SLACK_SHOWN: i64 = 10;
139
140#[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
149fn 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
179fn 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
184fn rounded(seconds: i64) -> i64 {
186 (seconds + 299).div_euclid(300) * 300
187}
188
189pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if planned.waits_for.iter().any(|w| !laid.contains_key(w.as_str())) {
356 view.more += 1;
357 continue;
358 }
359 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 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 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 let after = planned.waits_for.iter().filter_map(|w| laid.get(w.as_str())).max().map_or(i64::MIN, |end| end + pause);
385 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 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 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 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 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 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 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 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 view.from = whole_hour(view.from, &zone, false);
485 view.to = whole_hour(view.to, &zone, true);
486 view
487}
488
489fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(blocks, vec![("margin", 660), ("task", 675), ("margin", 725)]);
737 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 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 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 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 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 day(now, events, &made, tasks, &settings)
776 }
777
778 #[test]
779 fn a_task_pinned_to_its_block_is_laid_there() {
780 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 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 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 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 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 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 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 assert_eq!(plan(&tasks, now.date(), &settings, &BTreeMap::new(), &BTreeSet::new()).slack, made.slack);
851 }
852
853 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 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 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 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 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 assert_eq!(side_by_side(&[(540, 600), (600, 660)]), vec![(0, 1), (0, 1)]);
930 }
931}