Skip to main content

sioul_core/
taskview.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What the task pages show, in words: the next step and why, the board, the
5//! list, the timeline, one task in full. The core decides and words it; the
6//! window only lays it out (docs/architecture.md).
7//!
8//! The words follow docs/tasks.md: nothing is "overdue", "late" or "missed";
9//! a date asked is said as the time left, and once passed only as the date it
10//! was; a day to start that passed says nothing at all; nothing commands, and
11//! nothing counts what you did not do.
12
13use crate::projects::Project;
14use crate::i18n::Translator;
15use crate::links::{Kind, Related};
16use crate::plan::{Column, Plan, Planned};
17use crate::tasks::{Status, Task, TaskEdit};
18use crate::today::{FOG_MINUTES, Weather};
19use fluent_bundle::FluentArgs;
20use jiff::civil::Date;
21use jiff::{Span, Timestamp, tz::TimeZone};
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24
25/// What every view needs.
26pub struct Context<'a> {
27    /// What the pages show: one kind, one category, or everything.
28    pub filter: &'a Filter,
29    /// Offices are open now; else, when they open, in words ("Monday at 9:00").
30    pub offices: Offices,
31    pub tasks: &'a [Task],
32    pub plan: &'a Plan,
33    pub today: Date,
34    pub tr: &'a Translator,
35    pub projects: &'a [Project],
36    /// Minutes spent on each task, all time.
37    pub spent: &'a BTreeMap<String, u32>,
38    /// The last "where I stopped" line of each task.
39    pub stopped: &'a BTreeMap<String, String>,
40}
41
42/// What the task pages show: tasks of one kind, of one category; empty, all of them.
43/// The plan stays whole: a call that waits for a message still waits for it.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Filter {
46    #[serde(default)]
47    pub kind: String,
48    #[serde(default)]
49    pub category: String,
50    /// One project ("" for all); kept apart, as the page's own choice.
51    #[serde(skip)]
52    pub project: String,
53    /// Quiet time: what it keeps (`quiet::QuietTasks`).
54    #[serde(skip)]
55    pub quiet: Option<crate::quiet::QuietTasks>,
56}
57
58impl Filter {
59    pub fn wants(&self, task: &Task) -> bool {
60        (self.kind.is_empty() || task.kind == self.kind)
61            && (self.category.is_empty() || task.categories.iter().any(|c| c.eq_ignore_ascii_case(&self.category)))
62            && (self.project.is_empty() || task.projects.iter().any(|c| c == &self.project))
63            && self.quiet.as_ref().is_none_or(|quiet| quiet.keeps(task))
64    }
65
66    pub fn is_empty(&self) -> bool {
67        self.kind.is_empty() && self.category.is_empty() && self.project.is_empty() && self.quiet.is_none()
68    }
69}
70
71/// Whether offices are open now, for tasks that need one.
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct Offices {
74    pub open: bool,
75    /// When they open next, in words; empty when open.
76    pub next: String,
77    /// The moment: a task's own office (its hours, `Task::office_times`) is told by it.
78    pub now: Option<jiff::Zoned>,
79}
80
81impl Offices {
82    /// Always open: for places that do not care (tests, a list).
83    pub fn always() -> Offices {
84        Offices { open: true, next: String::new(), now: None }
85    }
86
87    /// Whether the office a task needs is open now: its own hours when it has
88    /// some, else offices' usual ones. A task needing none is always free.
89    pub fn open_for(&self, task: &Task) -> bool {
90        if !task.office_hours {
91            return true;
92        }
93        let own = crate::window::parse_ranges(&task.office_times);
94        match &self.now {
95            Some(now) if !own.is_empty() => crate::window::current(&own, now).is_some(),
96            _ => self.open,
97        }
98    }
99
100    /// When a task's own office opens next; None for the usual hours (`next` says them).
101    pub fn opening_for(&self, task: &Task) -> Option<jiff::Zoned> {
102        let own = crate::window::parse_ranges(&task.office_times);
103        self.now.as_ref().filter(|_| !own.is_empty()).and_then(|now| crate::window::next_opening(&own, now))
104    }
105}
106
107impl Context<'_> {
108    fn task(&self, uid: &str) -> Option<&Task> {
109        self.tasks.iter().find(|t| t.uid == uid)
110    }
111
112    fn planned(&self, uid: &str) -> Option<&Planned> {
113        self.plan.items.get(uid)
114    }
115
116    fn title(&self, uid: &str) -> String {
117        self.task(uid).map(|t| t.title.clone()).unwrap_or_else(|| uid.to_string())
118    }
119
120    fn project_title(&self, id: &str) -> String {
121        self.projects.iter().find(|c| c.id == id).map_or_else(|| id.to_string(), |c| c.title.clone())
122    }
123
124    fn args(&self, pairs: &[(&str, String)]) -> FluentArgs<'static> {
125        let mut args = FluentArgs::new();
126        for (name, value) in pairs {
127            args.set(name.to_string(), value.clone());
128        }
129        args
130    }
131
132    fn say(&self, id: &str, pairs: &[(&str, String)]) -> String {
133        self.tr.text(id, Some(&self.args(pairs)))
134    }
135
136    fn counted(&self, id: &str, n: usize, pairs: &[(&str, String)]) -> String {
137        let mut args = self.tr.counted(n);
138        for (name, value) in pairs {
139            args.set(name.to_string(), value.clone());
140        }
141        self.tr.text(id, Some(&args))
142    }
143}
144
145/// Days from `today` to `date`.
146fn days_between(today: Date, date: Date) -> i64 {
147    date.since(today).map_or(0, |s| i64::from(s.get_days()))
148}
149
150/// "About 15 minutes", "About an hour", "About 1 h 30".
151pub fn estimate_text(tr: &Translator, minutes: u32) -> String {
152    let mut args = FluentArgs::new();
153    match minutes {
154        0 => String::new(),
155        60 => tr.text("task-estimate-hour", None),
156        m if m < 60 => {
157            args.set("minutes", m);
158            tr.text("task-estimate-minutes", Some(&args))
159        }
160        m => {
161            args.set("hours", m / 60);
162            args.set("minutes", if m % 60 == 0 { String::new() } else { format!("{:02}", m % 60) });
163            tr.text("task-estimate-hours", Some(&args))
164        }
165    }
166}
167
168/// "1 minute so far", "25 minutes": the minutes as a number, so the plural
169/// is chosen by it (as a word, "1 minutes"); with a `date` before them.
170fn minutes_text(tr: &Translator, id: &str, minutes: u32, date: Option<String>) -> String {
171    let mut args = FluentArgs::new();
172    args.set("minutes", minutes);
173    if let Some(date) = date {
174        args.set("date", date);
175    }
176    tr.text(id, Some(&args))
177}
178
179/// "four weeks left", "five days left", "tomorrow", "today".
180fn time_left(cx: &Context, days: i64) -> String {
181    match days {
182        0 => cx.tr.text("task-left-today", None),
183        1 => cx.tr.text("task-left-tomorrow", None),
184        d if d < 14 => cx.counted("task-left-days", d as usize, &[]),
185        d if d < 61 => cx.counted("task-left-weeks", (d / 7) as usize, &[]),
186        d => cx.counted("task-left-months", (d / 30) as usize, &[]),
187    }
188}
189
190/// The date asked, as time left; once passed, only the date it was.
191fn due_text(cx: &Context, task: &Task) -> String {
192    let Some(due) = task.due_date() else { return String::new() };
193    if !task.status.is_open() {
194        return String::new();
195    }
196    let days = days_between(cx.today, due);
197    let date = cx.tr.day_month(due);
198    if days < 0 {
199        return cx.say("task-date-asked", &[("date", date)]);
200    }
201    cx.say("task-due", &[("date", date), ("left", time_left(cx, days))])
202}
203
204/// One task, as every view shows it.
205#[derive(Debug, Clone, Default, Serialize)]
206pub struct CardView {
207    pub uid: String,
208    pub key: String,
209    pub title: String,
210    pub list: String,
211    pub list_id: String,
212    pub color: Option<String>,
213    pub column: Column,
214    pub status: Status,
215    pub read_only: bool,
216    pub depth: usize,
217    /// "About 15 minutes".
218    pub estimate: String,
219    /// Minutes left, for the focus timer.
220    pub minutes: u32,
221    /// "By 30 October: four weeks left", or "Date asked: 30 October".
222    pub due: String,
223    /// The date asked is within a week: a quiet mark, never red.
224    pub due_soon: bool,
225    /// "Waits for: Fill the form", "Waits until Thursday 15 October".
226    pub waits: String,
227    /// "Frees two other steps".
228    pub unblocks: String,
229    /// "Two of three steps left".
230    pub steps: String,
231    pub has_steps: bool,
232    /// "Part of: The housing aid request".
233    pub parent: String,
234    pub parent_uid: String,
235    /// "Where you stopped: the income field".
236    pub stopped: String,
237    /// "45 minutes so far".
238    pub spent: String,
239    pub projects: Vec<String>,
240    pub project_ids: Vec<String>,
241    pub tags: Vec<String>,
242    /// One of `tasks::KINDS`, or "".
243    pub kind: String,
244    /// The plan cannot keep its date at this pace, said once and calmly.
245    pub tight: String,
246    pub in_loop: bool,
247    /// "Done Thursday 1 October".
248    pub done_on: String,
249    /// How many things it links to.
250    pub links: usize,
251    /// How heavy, from its costs when any is rated: "light", "usual", "heavy",
252    /// "rest" (it gives back); "" when none is rated (its word stands: `energy`).
253    pub level: &'static str,
254    /// Pinned to a time (its block, `blocks`): "Pinned: today, 14:00–14:45"; "" when not.
255    pub pinned: String,
256    /// The same, short, beside a pin on its row: "14:00" today, "Thu 8 Oct 10:00" another day; "" when not.
257    pub pinned_time: String,
258}
259
260/// Where a task is pinned, as its panel shows it.
261#[derive(Debug, Clone, Default, Serialize)]
262pub struct PinView {
263    /// "today, 14:00–14:45", "Thursday 8 October, 10:00–10:30".
264    pub when: String,
265    /// Its start on the clock, "2026-10-08T10:00": where "Do at…" opens.
266    pub at: String,
267    /// Its minutes, its margins aside.
268    pub minutes: u32,
269    /// The block's file, to open it as an event.
270    pub key: String,
271    /// Its calendar can only be read here.
272    pub read_only: bool,
273}
274
275/// "today, 14:00–14:45", "Thursday 8 October, 10:00–10:30": a block's time in words.
276fn pinned_when(cx: &Context, pin: &crate::blocks::Pin) -> String {
277    let zone = cx.offices.now.as_ref().map_or_else(TimeZone::system, |z| z.time_zone().clone());
278    let clock = |seconds: i64| Timestamp::from_second(seconds).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default();
279    let (from, to) = (clock(pin.start), clock(pin.end));
280    if pin.date == cx.today {
281        cx.say("task-pinned-today", &[("from", from), ("to", to)])
282    } else {
283        cx.say("task-pinned-day", &[("day", cx.tr.day_in(pin.date, cx.today)), ("from", from), ("to", to)])
284    }
285}
286
287/// Where a task is pinned, for its panel; None when it is not.
288pub fn pin_view(cx: &Context, uid: &str) -> Option<PinView> {
289    let pin = cx.plan.pins.get(uid)?;
290    let zone = cx.offices.now.as_ref().map_or_else(TimeZone::system, |z| z.time_zone().clone());
291    let at = Timestamp::from_second(pin.start).ok()?.to_zoned(zone).strftime("%Y-%m-%dT%H:%M").to_string();
292    Some(PinView { when: pinned_when(cx, pin), at, minutes: pin.minutes(), key: pin.key.clone(), read_only: pin.read_only })
293}
294
295/// A task in words.
296pub fn card(cx: &Context, task: &Task) -> CardView {
297    let planned = cx.planned(&task.uid).cloned().unwrap_or_default();
298    let waits = if !planned.waits_for.is_empty() {
299        let titles: Vec<String> = planned.waits_for.iter().map(|u| cx.title(u)).collect();
300        cx.say("task-waits-for", &[("titles", titles.join(", "))])
301    } else if let Some(day) = planned.not_before.filter(|d| *d > cx.today) {
302        cx.say("task-waits-until", &[("date", cx.tr.day(day))])
303    } else {
304        String::new()
305    };
306    let steps_total = cx.tasks.iter().filter(|t| t.parent() == Some(task.uid.as_str()) && t.status != Status::Cancelled).count();
307    let steps = if steps_total > 0 && task.status.is_open() {
308        cx.counted("task-steps-left", planned.open_steps, &[("total", cx.tr.count(steps_total, false, false))])
309    } else {
310        String::new()
311    };
312    let parent_uid = task.parent().unwrap_or_default().to_string();
313    let parent = if parent_uid.is_empty() { String::new() } else { cx.say("task-part-of", &[("title", cx.title(&parent_uid))]) };
314    let spent = cx.spent.get(&task.uid).copied().unwrap_or(0);
315    let tight = match (planned.tight, task.due_date(), planned.finish) {
316        (true, Some(due), Some(_)) => cx.say("task-tight", &[("date", cx.tr.day_month(due))]),
317        _ => String::new(),
318    };
319    let done_on = match (task.status, task.completed) {
320        (Status::Completed, Some(at)) => Timestamp::from_second(at).map(|t| cx.say("task-done-on", &[("date", cx.tr.day(t.to_zoned(TimeZone::system()).date()))])).unwrap_or_default(),
321        _ => String::new(),
322    };
323    CardView {
324        uid: task.uid.clone(),
325        key: task.key.clone(),
326        title: if task.title.trim().is_empty() { cx.tr.text("task-untitled", None) } else { task.title.clone() },
327        list: task.list.clone(),
328        list_id: task.list_id.clone(),
329        color: task.color.clone(),
330        column: planned.column,
331        status: task.status,
332        read_only: task.read_only,
333        depth: planned.depth,
334        estimate: if steps_total > 0 { String::new() } else { estimate_text(cx.tr, task.estimate) },
335        minutes: planned.left,
336        due: due_text(cx, task),
337        due_soon: task.status.is_open() && task.due_date().is_some_and(|d| (0..=7).contains(&days_between(cx.today, d))),
338        waits,
339        unblocks: if planned.unblocks > 0 && task.status.is_open() { cx.counted("task-unblocks", planned.unblocks, &[]) } else { String::new() },
340        steps,
341        has_steps: steps_total > 0,
342        parent,
343        parent_uid,
344        stopped: cx.stopped.get(&task.uid).filter(|s| !s.is_empty() && task.status.is_open()).map(|s| cx.say("task-stopped", &[("text", s.clone())])).unwrap_or_default(),
345        spent: if spent > 0 { minutes_text(cx.tr, "task-spent-time", spent, None) } else { String::new() },
346        projects: task.projects.iter().map(|c| cx.project_title(c)).collect(),
347        project_ids: task.projects.clone(),
348        tags: task.categories.clone(),
349        kind: task.kind.clone(),
350        tight,
351        in_loop: planned.in_loop,
352        done_on,
353        links: task.links.len() + task.contacts.len(),
354        level: task.demands.level().map_or("", crate::demands::Level::id),
355        pinned: cx.plan.pins.get(&task.uid).map(|pin| cx.say("task-pinned", &[("when", pinned_when(cx, pin))])).unwrap_or_default(),
356        pinned_time: cx.plan.pins.get(&task.uid).map(|pin| pinned_short(cx, pin)).unwrap_or_default(),
357    }
358}
359
360/// "14:00" today, "Thu 8 Oct 10:00" another day: where a task is pinned, beside a pin on its row.
361fn pinned_short(cx: &Context, pin: &crate::blocks::Pin) -> String {
362    let zone = cx.offices.now.as_ref().map_or_else(TimeZone::system, |z| z.time_zone().clone());
363    let Ok(start) = Timestamp::from_second(pin.start).map(|t| t.to_zoned(zone)) else { return String::new() };
364    if pin.date == cx.today { start.strftime("%H:%M").to_string() } else { cx.tr.date(&start, true) }
365}
366
367/// The Now page: the next step, why, and the one after it.
368#[derive(Debug, Clone, Default, Serialize)]
369pub struct NowView {
370    pub weather: Weather,
371    /// "Steps that need an open office come back Monday at 9:00."
372    pub office_note: String,
373    /// Said when only steps tied to what was put off are free: "Only steps tied to what you put off are free now."
374    pub tied_note: String,
375    /// What the day's weather changes, in a sentence; empty when clear.
376    pub weather_note: String,
377    pub now: Option<CardView>,
378    /// Why this one: short sentences.
379    pub why: Vec<String>,
380    /// Several steps were equal: Sioul picked.
381    pub picked: String,
382    pub then: Option<CardView>,
383    /// Up to two other steps free to start, behind a key.
384    pub others: Vec<CardView>,
385    /// Other steps started.
386    pub started: Vec<CardView>,
387    /// "Three things are started. Finish or park one?"
388    pub wip: String,
389    /// When nothing is free to start: what is going on, in a sentence.
390    pub empty: String,
391    pub loops: Vec<String>,
392    /// Done this week, newest first.
393    pub done_week: Vec<CardView>,
394    /// Tagged `joy` and free to start: offered, never proposed.
395    pub joy: Vec<CardView>,
396    /// After a heavy step done within two hours: something that gives back (energy "rest"), offered.
397    pub rest: Option<CardView>,
398}
399
400/// The open tasks that wait for this one, further down too, with a date
401/// asked still ahead: (title, date), the soonest first.
402fn dates_after(cx: &Context, uid: &str) -> Vec<(String, String, Date)> {
403    let mut seen = std::collections::BTreeSet::new();
404    let mut stack = vec![uid.to_string()];
405    let mut found = Vec::new();
406    while let Some(at) = stack.pop() {
407        for next in cx.plan.items.values().filter(|p| p.waits_for.contains(&at)) {
408            if seen.insert(next.uid.clone()) {
409                stack.push(next.uid.clone());
410                // Its own date, or its bigger task's: the step that waits is named, not the bigger task.
411                let step = cx.task(&next.uid).map(|t| t.title.clone()).unwrap_or_default();
412                let mut at = cx.task(&next.uid);
413                while let Some(task) = at {
414                    if let Some(due) = task.due_date().filter(|d| *d >= cx.today) {
415                        let whole = if task.uid == next.uid { String::new() } else { task.title.clone() };
416                        found.push((step.clone(), whole, due));
417                        break;
418                    }
419                    at = task.parent().and_then(|p| cx.task(p));
420                }
421            }
422        }
423    }
424    found.sort_by_key(|(_, _, d)| *d);
425    found
426}
427
428/// Whether two steps rank alike, before their titles and ages break the tie.
429fn alike(a: &Planned, b: &Planned, ta: &Task, tb: &Task) -> bool {
430    a.latest_start == b.latest_start && a.unblocks == b.unblocks && ta.priority == tb.priority && a.left == b.left && ta.status == tb.status
431}
432
433pub fn now(cx: &Context, weather: Weather, aside: &std::collections::BTreeSet<String>) -> NowView {
434    let mut view = NowView { weather, ..NowView::default() };
435    let ready: Vec<&Task> = cx.plan.ready.iter().filter_map(|u| cx.task(u)).filter(|t| cx.filter.wants(t)).collect();
436    // What was put off takes its stream with it for the day: what comes next is not tied to it.
437    let aside_streams: std::collections::BTreeSet<usize> = aside.iter().filter_map(|u| cx.plan.streams.get(u)).copied().collect();
438    let untied = |t: &&Task| cx.plan.streams.get(&t.uid).is_none_or(|s| !aside_streams.contains(s));
439    // A call to an office waits for its office to open: said once, quietly,
440    // with the soonest opening among them.
441    let closed: Vec<&Task> = ready.iter().copied().filter(|t| !cx.offices.open_for(t) && !aside.contains(&t.uid)).collect();
442    if !closed.is_empty() {
443        let own = closed.iter().filter_map(|t| cx.offices.opening_for(t)).min_by_key(jiff::Zoned::timestamp);
444        let usual = closed.iter().any(|t| crate::window::parse_ranges(&t.office_times).is_empty());
445        let when = match own {
446            Some(own) if !usual => cx.tr.when(&own),
447            _ => cx.offices.next.clone(),
448        };
449        view.office_note = cx.say("task-office-closed", &[("when", when)]);
450    }
451    let not_aside: Vec<&Task> = ready.iter().copied().filter(|t| !aside.contains(&t.uid) && cx.offices.open_for(t)).collect();
452    let others: Vec<&Task> = not_aside.iter().copied().filter(untied).collect();
453    if others.is_empty() && !not_aside.is_empty() {
454        view.tied_note = cx.tr.text("task-only-tied", None);
455    }
456    let free: Vec<&Task> = if others.is_empty() { not_aside } else { others };
457    // A step pinned to a time later (its block, `blocks`; or a time given today
458    // by a drag before blocks) waits for it: the others come first; it is still
459    // proposed when nothing else is free.
460    let stamp = cx.offices.now.as_ref().map_or_else(|| Timestamp::now().as_second(), |z| z.timestamp().as_second());
461    let zone = cx.offices.now.as_ref().map_or_else(TimeZone::system, |z| z.time_zone().clone());
462    let (later, sooner): (Vec<&Task>, Vec<&Task>) = free.into_iter().partition(|t| cx.plan.pins.get(&t.uid).map_or_else(|| t.at_on(cx.today, &zone), |pin| Some(pin.start)).is_some_and(|at| at > stamp + 15 * 60));
463    let free: Vec<&Task> = sooner.into_iter().chain(later).collect();
464    // Fog: only small steps; if none is small, the smallest.
465    let candidates: Vec<&Task> = if weather == Weather::Fog {
466        let small: Vec<&Task> = free.iter().copied().filter(|t| cx.planned(&t.uid).is_some_and(|p| p.left <= FOG_MINUTES)).collect();
467        if small.is_empty() { free.iter().copied().min_by_key(|t| cx.planned(&t.uid).map_or(u32::MAX, |p| p.left)).into_iter().collect() } else { small }
468    } else {
469        free.clone()
470    };
471    view.weather_note = match weather {
472        Weather::Clear => String::new(),
473        Weather::Haze => cx.tr.text("task-weather-haze-note", None),
474        Weather::Fog => cx.tr.text("task-weather-fog-note", None),
475    };
476    if let Some(first) = candidates.first() {
477        let planned = cx.planned(&first.uid).cloned().unwrap_or_default();
478        let mut why = Vec::new();
479        if first.status == Status::InProcess {
480            why.push(cx.tr.text("task-why-started", None));
481        }
482        let own_due = first.due_date().filter(|d| *d >= cx.today);
483        if let Some(due) = own_due {
484            why.push(cx.say("task-why-due", &[("date", cx.tr.day_month(due)), ("left", time_left(cx, days_between(cx.today, due)))]));
485        }
486        // A date further down the chain, sooner than its own: the reason it comes now.
487        if let Some((title, whole, due)) = dates_after(cx, &first.uid).into_iter().find(|(_, _, d)| own_due.is_none_or(|own| d < &own)) {
488            if whole.is_empty() {
489                why.push(cx.say("task-why-chain", &[("title", title), ("date", cx.tr.day_month(due))]));
490            } else {
491                why.push(cx.say("task-why-chain-part", &[("title", title), ("whole", whole), ("date", cx.tr.day_month(due))]));
492            }
493        }
494        if planned.unblocks > 0 {
495            why.push(cx.counted("task-why-unblocks", planned.unblocks, &[]));
496        }
497        // Heavy on a foggy day: only because nothing else is free.
498        if crate::capacity::level_of(first) == crate::demands::Level::Heavy && weather == Weather::Fog {
499            why.push(cx.tr.text("task-why-heavy-fog", None));
500        }
501        if why.is_empty() {
502            why.push(cx.tr.text("task-why-free", None));
503        }
504        view.why = why;
505        if let Some(second) = candidates.get(1)
506            && let (Some(a), Some(b)) = (cx.planned(&first.uid), cx.planned(&second.uid))
507            && alike(a, b, first, second)
508        {
509            view.picked = cx.tr.text("task-picked", None);
510        }
511        view.now = Some(card(cx, first));
512        if weather != Weather::Fog {
513            view.then = candidates.get(1).map(|t| card(cx, t));
514            view.others = candidates.iter().skip(2).take(2).map(|t| card(cx, t)).collect();
515        }
516    } else {
517        // Nothing free today: the step the plan puts first, and its day.
518        let coming = cx.plan.order.iter().filter_map(|u| Some((cx.task(u)?, cx.planned(u)?))).filter(|(_, p)| p.open_steps == 0 && p.start.is_some()).min_by_key(|(_, p)| p.start);
519        let quiet_only = cx.filter.quiet.is_some() && cx.filter.kind.is_empty() && cx.filter.category.is_empty() && cx.filter.project.is_empty();
520        view.empty = if !ready.is_empty() {
521            cx.tr.text("task-all-aside", None)
522        } else if quiet_only {
523            cx.tr.text("task-quiet-empty", None)
524        } else if !cx.filter.is_empty() {
525            cx.tr.text("task-nothing-filtered", None)
526        } else if let Some((task, planned)) = coming {
527            let day = planned.start.unwrap_or(cx.today);
528            cx.say("task-nothing-until", &[("date", cx.tr.day(day)), ("title", task.title.clone())])
529        } else {
530            cx.tr.text("task-nothing-ready", None)
531        };
532    }
533    let started: Vec<&Task> = cx.tasks.iter().filter(|t| t.status == Status::InProcess && cx.filter.wants(t) && cx.planned(&t.uid).is_some_and(|p| p.open_steps == 0)).collect();
534    view.started = started.iter().filter(|t| view.now.as_ref().is_none_or(|n| n.uid != t.uid)).map(|t| card(cx, t)).collect();
535    if started.len() > 3 {
536        view.wip = cx.counted("task-wip", started.len(), &[]);
537    }
538    view.loops = cx.plan.loops.iter().map(|l| cx.say("task-loop", &[("titles", l.iter().map(|u| cx.title(u)).collect::<Vec<_>>().join(", "))])).collect();
539    let week_start = cx.today.checked_sub(Span::new().days(i64::from(cx.today.weekday().to_monday_zero_offset()))).unwrap_or(cx.today);
540    let from = week_start.to_zoned(TimeZone::system()).map_or(0, |z| z.timestamp().as_second());
541    let mut done: Vec<&Task> = cx.tasks.iter().filter(|t| t.status == Status::Completed && t.completed.is_some_and(|c| c >= from) && cx.filter.wants(t)).collect();
542    done.sort_by_key(|t| std::cmp::Reverse(t.completed));
543    view.done_week = done.iter().map(|t| card(cx, t)).collect();
544    // What you do for joy (`words.tasks.joy`).
545    let joy = crate::words::current().tasks.joy.clone();
546    let joyful = |t: &Task| crate::words::named_in(&t.categories, &joy);
547    view.joy = cx
548        .plan
549        .order
550        .iter()
551        .filter_map(|u| cx.task(u))
552        .filter(|t| joyful(t) && cx.filter.wants(t) && cx.planned(&t.uid).is_some_and(|p| p.column == Column::Ready && p.open_steps == 0))
553        .map(|t| card(cx, t))
554        .collect();
555    // Energy accounting: a deposit after a withdrawal (Toudal & Attwood), offered, never planned in.
556    let lately = jiff::Timestamp::now().as_second() - 2 * 3600;
557    let level = crate::capacity::level_of;
558    if cx.tasks.iter().any(|t| t.status == Status::Completed && level(t) == crate::demands::Level::Heavy && t.completed.is_some_and(|c| c >= lately)) {
559        view.rest = cx.tasks.iter().find(|t| level(t) == crate::demands::Level::Rest && crate::plan::is_optional(t) && t.status.is_open() && cx.filter.wants(t) && cx.planned(&t.uid).is_some_and(|p| p.column != Column::Waiting && p.open_steps == 0)).map(|t| card(cx, t));
560    }
561    view
562}
563
564/// One column of the board.
565#[derive(Debug, Clone, Serialize)]
566pub struct ColumnView {
567    pub id: Column,
568    pub title: String,
569    pub cards: Vec<CardView>,
570}
571
572/// The board: free to start, started, waiting, done.
573#[derive(Debug, Clone, Serialize)]
574pub struct BoardView {
575    pub columns: Vec<ColumnView>,
576    pub wip: String,
577}
578
579/// The board shows the steps you act on: a bigger task stands for itself
580/// only when all its steps are done.
581pub fn board(cx: &Context, project: Option<&str>) -> BoardView {
582    let in_project = |t: &Task| project.is_none_or(|c| t.projects.iter().any(|x| x == c)) && cx.filter.wants(t);
583    let acted_on = |t: &&Task| cx.planned(&t.uid).is_some_and(|p| p.open_steps == 0 && (!p.optional || t.status != Status::NeedsAction)) && in_project(t);
584    let ordered: Vec<&Task> = cx.plan.order.iter().filter_map(|u| cx.task(u)).filter(acted_on).collect();
585    let fortnight = cx.today.checked_sub(Span::new().days(14)).unwrap_or(cx.today).to_zoned(TimeZone::system()).map_or(0, |z| z.timestamp().as_second());
586    let mut done: Vec<&Task> = cx.tasks.iter().filter(|t| t.status == Status::Completed && t.completed.is_some_and(|c| c >= fortnight)).filter(acted_on).collect();
587    done.sort_by_key(|t| std::cmp::Reverse(t.completed));
588    let column = |id: Column, key: &str, tasks: Vec<&Task>| ColumnView { id, title: cx.tr.text(key, None), cards: tasks.into_iter().map(|t| card(cx, t)).collect() };
589    let of = |c: Column| ordered.iter().copied().filter(|t| cx.planned(&t.uid).is_some_and(|p| p.column == c)).collect::<Vec<_>>();
590    let doing = of(Column::Doing);
591    BoardView {
592        wip: if doing.len() > 3 { cx.counted("task-wip", doing.len(), &[]) } else { String::new() },
593        columns: vec![column(Column::Ready, "board-ready", of(Column::Ready)), column(Column::Doing, "board-doing", doing), column(Column::Waiting, "board-waiting", of(Column::Waiting)), column(Column::Done, "board-done", done)],
594    }
595}
596
597/// A group of the list: a project, or a list.
598#[derive(Debug, Clone, Serialize)]
599pub struct GroupView {
600    pub id: String,
601    pub title: String,
602    pub rows: Vec<CardView>,
603}
604
605#[derive(Debug, Clone, Serialize)]
606pub struct ListView {
607    pub groups: Vec<GroupView>,
608}
609
610/// The list: every open task, a bigger task followed by its steps, in the
611/// plan's order, grouped by project ("project") or by list ("list"). Done tasks of
612/// the last two weeks come last when `done` is asked.
613pub fn list(cx: &Context, by: &str, done: bool, query: &str) -> ListView {
614    let fold = |t: &str| crate::text::fold(t).into_iter().collect::<String>();
615    let words: Vec<String> = query.split_whitespace().map(fold).collect();
616    let matches = |t: &Task| words.iter().all(|w| fold(&format!("{} {} {}", t.title, t.notes, t.categories.join(" "))).contains(w.as_str()));
617    let fortnight = cx.today.checked_sub(Span::new().days(14)).unwrap_or(cx.today).to_zoned(TimeZone::system()).map_or(0, |z| z.timestamp().as_second());
618    let shown = |t: &Task| (t.status.is_open() || (done && t.status == Status::Completed && t.completed.is_some_and(|c| c >= fortnight))) && matches(t) && cx.filter.wants(t);
619    // Open tasks in the plan's order, then done ones, newest first.
620    let mut ordered: Vec<&Task> = cx.plan.order.iter().filter_map(|u| cx.task(u)).collect();
621    let mut finished: Vec<&Task> = cx.tasks.iter().filter(|t| t.status == Status::Completed).collect();
622    finished.sort_by_key(|t| std::cmp::Reverse(t.completed));
623    ordered.extend(finished);
624    let position: BTreeMap<&str, usize> = ordered.iter().enumerate().map(|(i, t)| (t.uid.as_str(), i)).collect();
625    // A bigger task, then its steps, each after the other, each once.
626    fn outline<'a>(task: &'a Task, all: &[&'a Task], position: &BTreeMap<&str, usize>, out: &mut Vec<&'a Task>, placed: &mut std::collections::BTreeSet<&'a str>, depth: usize) {
627        if !placed.insert(task.uid.as_str()) {
628            return;
629        }
630        out.push(task);
631        if depth > 8 {
632            return;
633        }
634        let mut steps: Vec<&Task> = all.iter().copied().filter(|t| t.parent() == Some(task.uid.as_str())).collect();
635        steps.sort_by_key(|t| position.get(t.uid.as_str()).copied().unwrap_or(usize::MAX));
636        for step in steps {
637            outline(step, all, position, out, placed, depth + 1);
638        }
639    }
640    let known: std::collections::BTreeSet<&str> = ordered.iter().map(|t| t.uid.as_str()).collect();
641    let roots: Vec<&Task> = ordered.iter().copied().filter(|t| t.parent().is_none_or(|p| !known.contains(p))).collect();
642    // Each bigger task with its steps; the steps go where it goes. Then what no
643    // root reaches: tasks that are steps of each other in a loop (a file from
644    // elsewhere), steps nested too deep; else they would not be listed at all.
645    let mut groups: Vec<GroupView> = Vec::new();
646    let mut placed: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
647    for root in roots.into_iter().chain(ordered.iter().copied()) {
648        if placed.contains(root.uid.as_str()) {
649            continue;
650        }
651        let mut rows: Vec<&Task> = Vec::new();
652        outline(root, &ordered, &position, &mut rows, &mut placed, 0);
653        let optional = cx.planned(&root.uid).is_some_and(|p| p.optional);
654        let joyful = rows.iter().any(|t| crate::words::named_in(&t.categories, &crate::words::current().tasks.joy));
655        let (id, title) = match by {
656            _ if optional && joyful => (OPTIONAL_JOY.to_string(), cx.tr.text("task-group-joy", None)),
657            _ if optional => (OPTIONAL_SOMEDAY.to_string(), cx.tr.text("task-group-someday", None)),
658            "list" => (root.list_id.clone(), root.list.clone()),
659            _ => match rows.iter().find_map(|t| t.projects.first()) {
660                Some(project) => (project.clone(), cx.project_title(project)),
661                None => (String::new(), cx.tr.text("task-no-project", None)),
662            },
663        };
664        let cards: Vec<CardView> = rows.into_iter().filter(|t| shown(t)).map(|t| card(cx, t)).collect();
665        if cards.is_empty() {
666            continue;
667        }
668        match groups.iter_mut().find(|g| g.id == id) {
669            Some(group) => group.rows.extend(cards),
670            None => groups.push(GroupView { id, title, rows: cards }),
671        }
672    }
673    // Tasks without a project come after the projects; what you do if you want, and what is parked, last.
674    groups.sort_by_key(|g| match g.id.as_str() {
675        OPTIONAL_JOY => 2,
676        OPTIONAL_SOMEDAY => 3,
677        "" => 1,
678        _ => 0,
679    });
680    ListView { groups }
681}
682
683/// The groups of the list that hold optional tasks.
684const OPTIONAL_JOY: &str = "\u{1}joy";
685const OPTIONAL_SOMEDAY: &str = "\u{1}someday";
686
687/// One day of the timeline's head.
688#[derive(Debug, Clone, Serialize)]
689pub struct DayHead {
690    pub date: String,
691    /// "5".
692    pub day: String,
693    /// "Mon".
694    pub weekday: String,
695    /// "October", on the first day shown and the first of each month.
696    pub month: String,
697    /// No room for tasks that day.
698    pub rest: bool,
699}
700
701/// One bar of the timeline.
702#[derive(Debug, Clone, Serialize)]
703pub struct BarView {
704    pub uid: String,
705    pub key: String,
706    pub title: String,
707    pub depth: usize,
708    /// Days from today.
709    pub start: i64,
710    pub length: i64,
711    /// The date asked, in days from today, when it falls in view.
712    pub due: Option<i64>,
713    pub tight: bool,
714    pub column: Column,
715    pub has_steps: bool,
716    pub color: Option<String>,
717    /// "Waits for: …", "Frees two other steps", in one line.
718    pub detail: String,
719}
720
721#[derive(Debug, Clone, Serialize)]
722pub struct TimelineView {
723    pub days: Vec<DayHead>,
724    pub rows: Vec<BarView>,
725    /// "The plan reaches Friday 6 November." or why it is empty.
726    pub note: String,
727}
728
729/// The timeline (Gantt chart): each open task on the days the plan gives it,
730/// from today; the dates asked as marks. `project` keeps one project.
731pub fn timeline(cx: &Context, project: Option<&str>, rest_days: &[bool; 7]) -> TimelineView {
732    let in_project = |t: &Task| project.is_none_or(|c| t.projects.iter().any(|x| x == c)) && cx.filter.wants(t);
733    let list = list(cx, "plan", false, "");
734    let ordered: Vec<&Task> = list.groups.iter().flat_map(|g| g.rows.iter()).filter_map(|c| cx.task(&c.uid)).filter(|t| in_project(t)).collect();
735    let last = ordered
736        .iter()
737        .filter_map(|t| {
738            let p = cx.planned(&t.uid)?;
739            let finish = p.finish.map(|f| days_between(cx.today, f));
740            let due = t.due_date().map(|d| days_between(cx.today, d));
741            finish.max(due)
742        })
743        .max()
744        .unwrap_or(0);
745    let span = (last + 3).clamp(14, 180);
746    let mut days = Vec::new();
747    for i in 0..span {
748        let Ok(date) = cx.today.checked_add(Span::new().days(i)) else { break };
749        days.push(DayHead {
750            date: date.to_string(),
751            day: date.day().to_string(),
752            weekday: cx.tr.weekday_short(date),
753            month: if i == 0 || date.day() == 1 { cx.tr.month_year(date) } else { String::new() },
754            rest: rest_days[date.weekday().to_monday_zero_offset() as usize],
755        });
756    }
757    let rows: Vec<BarView> = ordered
758        .iter()
759        .filter_map(|t| {
760            let p = cx.planned(&t.uid)?;
761            let (start, finish) = (p.start?, p.finish?);
762            let start = days_between(cx.today, start).max(0);
763            let length = (days_between(cx.today, finish).max(start) - start + 1).max(1);
764            let c = card(cx, t);
765            let detail = [c.waits.clone(), c.unblocks.clone(), c.estimate.clone()].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · ");
766            Some(BarView {
767                uid: t.uid.clone(),
768                key: t.key.clone(),
769                title: c.title,
770                depth: p.depth,
771                start,
772                length,
773                due: t.due_date().map(|d| days_between(cx.today, d)).filter(|d| (0..span).contains(d)),
774                tight: p.tight,
775                column: p.column,
776                has_steps: c.has_steps,
777                color: t.color.clone(),
778                detail,
779            })
780        })
781        .collect();
782    let note = if rows.is_empty() {
783        cx.tr.text("timeline-empty", None)
784    } else {
785        let end = cx.today.checked_add(Span::new().days(rows.iter().map(|r| r.start + r.length - 1).max().unwrap_or(0))).unwrap_or(cx.today);
786        cx.say("timeline-reaches", &[("date", cx.tr.day(end))])
787    };
788    TimelineView { days, rows, note }
789}
790
791/// Something tied to the task open, in words.
792#[derive(Debug, Clone, Serialize)]
793pub struct RelatedView {
794    pub uri: String,
795    pub kind: Kind,
796    pub title: String,
797    pub detail: String,
798    /// "Its notes", "Made from", "Waits for"…
799    pub how: String,
800    /// The date, in words.
801    pub when: String,
802    pub key: String,
803    pub found: bool,
804    /// A program on this computer: `key` is its folder, which opens instead.
805    pub program: bool,
806}
807
808/// What the Related panel shows.
809pub fn related_views(tr: &Translator, related: &[Related]) -> Vec<RelatedView> {
810    related
811        .iter()
812        .map(|r| RelatedView {
813            uri: r.uri.clone(),
814            kind: r.kind,
815            title: if r.title.trim().is_empty() { tr.text("task-untitled", None) } else { r.title.clone() },
816            // A note linked here whose file is not on this computer (yet): its name, and why.
817            detail: if !r.found && r.kind == Kind::Note {
818                tr.text("note-not-here", None)
819            } else if r.program {
820                // Said once, calmly: what opens instead, and that starting it stays yours.
821                tr.text("link-program", None)
822            } else {
823                r.detail.clone()
824            },
825            how: tr.text(&format!("how-{}", r.how), None),
826            when: r.when.and_then(|t| Timestamp::from_second(t).ok()).map(|t| tr.date(&t.to_zoned(TimeZone::system()), true)).unwrap_or_default(),
827            key: r.key.clone(),
828            found: r.found,
829            program: r.program,
830        })
831        .collect()
832}
833
834/// One task in full, for its panel.
835#[derive(Debug, Clone, Serialize)]
836pub struct DetailView {
837    pub card: CardView,
838    pub edit: TaskEdit,
839    pub steps: Vec<CardView>,
840    /// The steps' minutes added up, when they say: "Its steps add up to about 45 minutes".
841    pub steps_total: String,
842    /// What it waits for: (uid, title, its gap in words: "two weeks after
843    /// it", "" without one); what waits for it: (uid, title).
844    pub waits_for: Vec<(String, String, String)>,
845    pub frees: Vec<(String, String)>,
846    /// "Thursday 1 October: 25 minutes", newest first.
847    pub sessions: Vec<String>,
848    pub related: Vec<RelatedView>,
849    /// What its tags say it is for, its own area aside: "admin", "work+leisure".
850    pub area_tags: String,
851    /// How heavy, from its costs: as `CardView::level`.
852    pub level: &'static str,
853    /// Its first estimate, in minutes; None when unknown (`Task::estimate_first`).
854    pub estimate_first: Option<u32>,
855    /// The day of the newest rating said after it ("2026-10-06"), "" when none: `edit.felt` is that one.
856    pub felt_on: String,
857    /// On request: how tasks like it usually go against the first guess, in a
858    /// sentence; "" when there is nothing worth saying (`capacity::ratio_line`).
859    pub ratio_line: String,
860    /// Pinned to a time: its block; None when the plan places it.
861    pub pin: Option<PinView>,
862    /// "Do at…"'s length by default, in minutes: what the plan lays for it, its margins aside (`blocks::default_minutes`).
863    pub pin_minutes: u32,
864}
865
866/// The gap of `task`'s wait for `other`, in minutes, as the plan reads it
867/// (`plan::Graph`): a FINISHTOSTART or NEXT in the other, else its own DEPENDS-ON.
868fn wait_gap(cx: &Context, task: &Task, other: &str) -> i64 {
869    let theirs = cx.task(other).and_then(|t| t.relations.iter().find(|r| matches!(r.kind.as_str(), "FINISHTOSTART" | "NEXT") && r.uid == task.uid));
870    let mine = || task.relations.iter().find(|r| r.kind == "DEPENDS-ON" && r.uid == other);
871    theirs.or_else(mine).map_or(0, |r| r.gap.max(0))
872}
873
874/// A wait's gap in words: "a day after it", "two weeks after it", "three
875/// hours after it" (a gap another program wrote in hours); "" without one.
876fn gap_words(cx: &Context, minutes: i64) -> String {
877    let n = |m: i64| usize::try_from(m).unwrap_or(0);
878    match minutes {
879        m if m <= 0 => String::new(),
880        m if m % (7 * 1440) == 0 => cx.counted("task-waits-after-weeks", n(m / (7 * 1440)), &[]),
881        m if m % 1440 == 0 => cx.counted("task-waits-after-days", n(m / 1440), &[]),
882        m => cx.counted("task-waits-after-hours", n((m + 59) / 60), &[]),
883    }
884}
885
886pub fn detail(cx: &Context, task: &Task, sessions: &[crate::timelog::Session], related: &[Related]) -> DetailView {
887    let mut steps: Vec<&Task> = cx.tasks.iter().filter(|t| t.parent() == Some(task.uid.as_str()) && t.status != Status::Cancelled).collect();
888    let order: BTreeMap<&str, usize> = cx.plan.order.iter().enumerate().map(|(i, u)| (u.as_str(), i)).collect();
889    steps.sort_by_key(|t| (!t.status.is_open(), order.get(t.uid.as_str()).copied().unwrap_or(usize::MAX)));
890    let total: u32 = steps.iter().fold(0u32, |sum, t| sum.saturating_add(t.estimate));
891    let waits_for = cx.planned(&task.uid).map(|p| p.waits_for.iter().map(|u| (u.clone(), cx.title(u), gap_words(cx, wait_gap(cx, task, u)))).collect()).unwrap_or_default();
892    let frees = cx.plan.items.values().filter(|p| p.waits_for.contains(&task.uid)).map(|p| (p.uid.clone(), cx.title(&p.uid))).collect();
893    let mut mine: Vec<&crate::timelog::Session> = sessions.iter().filter(|s| s.task == task.uid).collect();
894    mine.sort_by_key(|s| std::cmp::Reverse(s.start));
895    let sessions = mine
896        .iter()
897        .take(8)
898        .map(|s| {
899            let day = Timestamp::from_second(s.start).map(|t| cx.tr.day(t.to_zoned(TimeZone::system()).date())).unwrap_or_default();
900            minutes_text(cx.tr, "task-session-time", s.minutes, Some(day))
901        })
902        .collect();
903    DetailView {
904        card: card(cx, task),
905        edit: TaskEdit::of(task),
906        steps: steps.iter().map(|t| card(cx, t)).collect(),
907        steps_total: if total > 0 && steps.len() > 1 { cx.say("task-steps-total", &[("estimate", estimate_text(cx.tr, total).to_lowercase())]) } else { String::new() },
908        waits_for,
909        frees,
910        sessions,
911        related: related_views(cx.tr, related),
912        area_tags: cx.filter.quiet.as_ref().map_or_else(|| crate::areas::TaskAreas::usual().by_tags(task), |q| q.areas.by_tags(task)).id(),
913        level: task.demands.level().map_or("", crate::demands::Level::id),
914        estimate_first: task.estimate_first,
915        felt_on: task.felt.last().and_then(|f| f.on).map(|d| d.to_string()).unwrap_or_default(),
916        ratio_line: String::new(),
917        pin: pin_view(cx, &task.uid),
918        pin_minutes: crate::blocks::default_minutes(task, cx.planned(&task.uid).map(|p| p.laid).filter(|l| *l > 0), 30),
919    }
920}
921
922/// What finishing a task changed, in one line: what it frees, or the bigger
923/// task now complete. Empty when nothing visible changed.
924pub fn done_effect(cx: &Context, before: &Plan, uid: &str) -> String {
925    let freed: Vec<String> = cx
926        .plan
927        .items
928        .values()
929        .filter(|p| p.column == Column::Ready && before.items.get(&p.uid).is_some_and(|b| b.column == Column::Waiting) && p.uid != uid)
930        .map(|p| cx.title(&p.uid))
931        .collect();
932    if !freed.is_empty() {
933        return cx.say("task-done-frees", &[("titles", freed.join(", "))]);
934    }
935    if let Some(parent) = cx.task(uid).and_then(|t| t.parent()).and_then(|p| cx.planned(p)).filter(|p| p.open_steps == 0) {
936        return cx.say("task-done-all-steps", &[("title", cx.title(&parent.uid))]);
937    }
938    String::new()
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944    use crate::plan::{Settings, plan};
945    use crate::tasks::Relation;
946    use std::collections::BTreeSet;
947
948    fn task(uid: &str, title: &str) -> Task {
949        Task { uid: uid.into(), title: title.into(), estimate: 15, ..Task::default() }
950    }
951
952    #[test]
953    fn the_now_page_in_words() {
954        let today: Date = "2026-10-05".parse().unwrap();
955        let mut form = task("form", "Fill the housing form");
956        form.due = "2026-10-30".into();
957        let mut send = task("send", "Send the certificate");
958        send.relations.push(Relation { kind: "DEPENDS-ON".into(), uid: "form".into(), gap: 0 });
959        let mut gone = task("gone", "Pay the bill");
960        gone.due = "2026-10-01".into();
961        gone.relations.push(Relation { kind: "DEPENDS-ON".into(), uid: "form".into(), gap: 0 });
962        let tasks = vec![form, send, gone];
963        let plan = plan(&tasks, today, &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
964        let tr = Translator::new("en");
965        let everything = Filter::default();
966        let cx = Context { filter: &everything, offices: Offices::always(), tasks: &tasks, plan: &plan, today, tr: &tr, projects: &[], spent: &BTreeMap::new(), stopped: &BTreeMap::new() };
967        let view = now(&cx, Weather::Clear, &BTreeSet::new());
968        let first = view.now.clone().unwrap();
969        assert_eq!(first.title, "Fill the housing form");
970        assert_eq!(view.why, vec!["Its date is 30 October: three weeks left.", "It frees two other steps."]);
971        assert_eq!((first.due.as_str(), first.estimate.as_str()), ("By 30 October: three weeks left", "About 15 minutes"));
972        let waiting = card(&cx, &tasks[2]);
973        assert_eq!((waiting.waits.as_str(), waiting.due.as_str()), ("Waits for: Fill the housing form", "Date asked: 1 October"));
974        let fr = Translator::new("fr");
975        let cx = Context { tr: &fr, ..cx };
976        assert_eq!(now(&cx, Weather::Clear, &BTreeSet::new()).why[0], "Sa date est le 30 octobre\u{202f}: encore trois semaines.");
977        // Minutes chosen by their number: "1 minute", not "1 minutes".
978        assert_eq!((minutes_text(&tr, "task-spent-time", 1, None), minutes_text(&tr, "task-spent-time", 25, None)), ("1 minute so far".to_string(), "25 minutes so far".to_string()));
979        assert_eq!(minutes_text(&fr, "task-session-time", 1, Some("lundi 5 octobre".into())), "lundi 5 octobre\u{202f}: 1 minute");
980    }
981
982    #[test]
983    fn one_kind_at_a_time() {
984        let today: Date = "2026-10-05".parse().unwrap();
985        let mut call = task("call", "Call the bank");
986        call.kind = "call".into();
987        call.categories = vec!["Money".into()];
988        // Waits for a message: the plan keeps the wait when messages are not shown.
989        call.relations.push(Relation { kind: "DEPENDS-ON".into(), uid: "write".into(), gap: 0 });
990        let mut write = task("write", "Write to the bank");
991        write.kind = "write".into();
992        let mut other = task("other", "Call the CAF");
993        other.kind = "call".into();
994        let tasks = vec![call, write, other];
995        let plan = plan(&tasks, today, &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
996        let tr = Translator::new("en");
997        let calls = Filter { kind: "call".into(), ..Filter::default() };
998        let cx = Context { filter: &calls, offices: Offices::always(), tasks: &tasks, plan: &plan, today, tr: &tr, projects: &[], spent: &BTreeMap::new(), stopped: &BTreeMap::new() };
999        let titles: Vec<String> = list(&cx, "project", false, "").groups.iter().flat_map(|g| g.rows.iter().map(|r| r.title.clone())).collect();
1000        // In the plan's order: the free call first, the one that waits after.
1001        assert_eq!(titles, vec!["Call the CAF", "Call the bank"]);
1002        assert_eq!(now(&cx, Weather::Clear, &BTreeSet::new()).now.unwrap().title, "Call the CAF", "the bank's call still waits for the message");
1003        assert_eq!(card(&cx, &tasks[0]).waits, "Waits for: Write to the bank");
1004        let money = Filter { category: "money".into(), ..Filter::default() };
1005        let cx = Context { filter: &money, ..cx };
1006        assert_eq!(board(&cx, None).columns.iter().map(|c| c.cards.len()).sum::<usize>(), 1);
1007    }
1008
1009    #[test]
1010    fn steps_of_each_other_are_still_listed() {
1011        // Two tasks each a step of the other (a file from elsewhere): listed once each, not lost.
1012        let today: Date = "2026-10-05".parse().unwrap();
1013        let step_of = |uid: &str, title: &str, parent: &str| Task { relations: vec![Relation { kind: "PARENT".into(), uid: parent.into(), gap: 0 }], ..task(uid, title) };
1014        let tasks = vec![step_of("a", "First", "b"), step_of("b", "Second", "a"), task("c", "Third")];
1015        let plan = plan(&tasks, today, &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1016        let tr = Translator::new("en");
1017        let everything = Filter::default();
1018        let cx = Context { filter: &everything, offices: Offices::always(), tasks: &tasks, plan: &plan, today, tr: &tr, projects: &[], spent: &BTreeMap::new(), stopped: &BTreeMap::new() };
1019        let mut titles: Vec<String> = list(&cx, "project", false, "").groups.iter().flat_map(|g| g.rows.iter().map(|r| r.title.clone())).collect();
1020        titles.sort();
1021        assert_eq!(titles, vec!["First", "Second", "Third"]);
1022    }
1023
1024    /// A wait's gap said beside it in the task's details, wherever it is
1025    /// written: in the task that comes first (the import's and the form's
1026    /// FINISHTOSTART), or in the one that waits.
1027    #[test]
1028    fn a_waits_gap_is_said() {
1029        let today: Date = "2026-10-05".parse().unwrap();
1030        let mut letter = task("letter", "Send the letter");
1031        letter.relations.push(Relation { kind: "FINISHTOSTART".into(), uid: "call".into(), gap: 14 * 1440 });
1032        let mut form = task("form", "Fill the form");
1033        form.relations.push(Relation { kind: "NEXT".into(), uid: "call".into(), gap: 3 * 1440 });
1034        let mut call = task("call", "Call about the answer");
1035        call.relations.push(Relation { kind: "DEPENDS-ON".into(), uid: "photo".into(), gap: 90 });
1036        call.relations.push(Relation { kind: "DEPENDS-ON".into(), uid: "copy".into(), gap: 0 });
1037        let tasks = vec![letter, form, call, task("photo", "Take the photo"), task("copy", "Copy the form")];
1038        let plan = plan(&tasks, today, &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1039        for (language, said) in [
1040            ("en", [("letter", "two weeks after it"), ("form", "three days after it"), ("photo", "two hours after it"), ("copy", "")]),
1041            ("fr", [("letter", "deux semaines après elle"), ("form", "trois jours après elle"), ("photo", "deux heures après elle"), ("copy", "")]),
1042        ] {
1043            let tr = Translator::new(language);
1044            let everything = Filter::default();
1045            let cx = Context { filter: &everything, offices: Offices::always(), tasks: &tasks, plan: &plan, today, tr: &tr, projects: &[], spent: &BTreeMap::new(), stopped: &BTreeMap::new() };
1046            let view = detail(&cx, &tasks[2], &[], &[]);
1047            for (uid, words) in said {
1048                let wait = view.waits_for.iter().find(|w| w.0 == uid).unwrap_or_else(|| panic!("{uid} in {:?}", view.waits_for));
1049                assert_eq!(wait.2, words, "{language} {uid}");
1050            }
1051        }
1052        assert_eq!(gap_words(&Context { filter: &Filter::default(), offices: Offices::always(), tasks: &tasks, plan: &plan, today, tr: &Translator::new("en"), projects: &[], spent: &BTreeMap::new(), stopped: &BTreeMap::new() }, 1440), "a day after it");
1053    }
1054}