1use 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
25pub struct Context<'a> {
27 pub filter: &'a Filter,
29 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 pub spent: &'a BTreeMap<String, u32>,
38 pub stopped: &'a BTreeMap<String, String>,
40}
41
42#[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 #[serde(skip)]
52 pub project: String,
53 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct Offices {
74 pub open: bool,
75 pub next: String,
77 pub now: Option<jiff::Zoned>,
79}
80
81impl Offices {
82 pub fn always() -> Offices {
84 Offices { open: true, next: String::new(), now: None }
85 }
86
87 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 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
145fn days_between(today: Date, date: Date) -> i64 {
147 date.since(today).map_or(0, |s| i64::from(s.get_days()))
148}
149
150pub 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
168fn 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
179fn 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
190fn 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#[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 pub estimate: String,
219 pub minutes: u32,
221 pub due: String,
223 pub due_soon: bool,
225 pub waits: String,
227 pub unblocks: String,
229 pub steps: String,
231 pub has_steps: bool,
232 pub parent: String,
234 pub parent_uid: String,
235 pub stopped: String,
237 pub spent: String,
239 pub projects: Vec<String>,
240 pub project_ids: Vec<String>,
241 pub tags: Vec<String>,
242 pub kind: String,
244 pub tight: String,
246 pub in_loop: bool,
247 pub done_on: String,
249 pub links: usize,
251 pub level: &'static str,
254 pub pinned: String,
256 pub pinned_time: String,
258}
259
260#[derive(Debug, Clone, Default, Serialize)]
262pub struct PinView {
263 pub when: String,
265 pub at: String,
267 pub minutes: u32,
269 pub key: String,
271 pub read_only: bool,
273}
274
275fn 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
287pub 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
295pub 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
360fn 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#[derive(Debug, Clone, Default, Serialize)]
369pub struct NowView {
370 pub weather: Weather,
371 pub office_note: String,
373 pub tied_note: String,
375 pub weather_note: String,
377 pub now: Option<CardView>,
378 pub why: Vec<String>,
380 pub picked: String,
382 pub then: Option<CardView>,
383 pub others: Vec<CardView>,
385 pub started: Vec<CardView>,
387 pub wip: String,
389 pub empty: String,
391 pub loops: Vec<String>,
392 pub done_week: Vec<CardView>,
394 pub joy: Vec<CardView>,
396 pub rest: Option<CardView>,
398}
399
400fn 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 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
428fn 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize)]
566pub struct ColumnView {
567 pub id: Column,
568 pub title: String,
569 pub cards: Vec<CardView>,
570}
571
572#[derive(Debug, Clone, Serialize)]
574pub struct BoardView {
575 pub columns: Vec<ColumnView>,
576 pub wip: String,
577}
578
579pub 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#[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
610pub 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 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 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 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 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
683const OPTIONAL_JOY: &str = "\u{1}joy";
685const OPTIONAL_SOMEDAY: &str = "\u{1}someday";
686
687#[derive(Debug, Clone, Serialize)]
689pub struct DayHead {
690 pub date: String,
691 pub day: String,
693 pub weekday: String,
695 pub month: String,
697 pub rest: bool,
699}
700
701#[derive(Debug, Clone, Serialize)]
703pub struct BarView {
704 pub uid: String,
705 pub key: String,
706 pub title: String,
707 pub depth: usize,
708 pub start: i64,
710 pub length: i64,
711 pub due: Option<i64>,
713 pub tight: bool,
714 pub column: Column,
715 pub has_steps: bool,
716 pub color: Option<String>,
717 pub detail: String,
719}
720
721#[derive(Debug, Clone, Serialize)]
722pub struct TimelineView {
723 pub days: Vec<DayHead>,
724 pub rows: Vec<BarView>,
725 pub note: String,
727}
728
729pub 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#[derive(Debug, Clone, Serialize)]
793pub struct RelatedView {
794 pub uri: String,
795 pub kind: Kind,
796 pub title: String,
797 pub detail: String,
798 pub how: String,
800 pub when: String,
802 pub key: String,
803 pub found: bool,
804 pub program: bool,
806}
807
808pub 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 detail: if !r.found && r.kind == Kind::Note {
818 tr.text("note-not-here", None)
819 } else if r.program {
820 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#[derive(Debug, Clone, Serialize)]
836pub struct DetailView {
837 pub card: CardView,
838 pub edit: TaskEdit,
839 pub steps: Vec<CardView>,
840 pub steps_total: String,
842 pub waits_for: Vec<(String, String, String)>,
845 pub frees: Vec<(String, String)>,
846 pub sessions: Vec<String>,
848 pub related: Vec<RelatedView>,
849 pub area_tags: String,
851 pub level: &'static str,
853 pub estimate_first: Option<u32>,
855 pub felt_on: String,
857 pub ratio_line: String,
860 pub pin: Option<PinView>,
862 pub pin_minutes: u32,
864}
865
866fn 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
874fn 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
922pub 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 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 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 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 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 #[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}