Skip to main content

things3_cloud/ui/views/
today.rs

1use crate::common::ICONS;
2use crate::ids::ThingsId;
3use crate::store::{Task, ThingsStore};
4use crate::ui::components::empty_text::EmptyText;
5use crate::ui::components::task_group::{TaskGroup, TaskGroupHeader};
6use crate::ui::components::tasks::{TaskList, TaskOptions};
7use iocraft::prelude::*;
8use std::collections::HashMap;
9use std::sync::Arc;
10
11const LIST_INDENT: u32 = 2;
12
13#[derive(Default)]
14struct AreaGroup<'a> {
15    tasks: Vec<&'a Task>,
16}
17
18#[derive(Default)]
19struct GroupedSection<'a> {
20    unscoped: Vec<&'a Task>,
21    by_project: Vec<(ThingsId, Vec<&'a Task>)>,
22    by_area: Vec<(ThingsId, AreaGroup<'a>)>,
23}
24
25fn header_text(items: &[Task]) -> String {
26    let project_count = items.iter().filter(|task| task.is_project()).count();
27    let task_count = items.iter().filter(|task| !task.is_project()).count();
28    if project_count > 0 {
29        let label = if project_count == 1 {
30            "project"
31        } else {
32            "projects"
33        };
34        format!(
35            "{} Today  ({} tasks, {} {})",
36            ICONS.today, task_count, project_count, label
37        )
38    } else {
39        format!("{} Today  ({} tasks)", ICONS.today, task_count)
40    }
41}
42
43fn has_regular(items: &[Task]) -> bool {
44    items.iter().any(|task| !task.evening)
45}
46
47fn has_evening(items: &[Task]) -> bool {
48    items.iter().any(|task| task.evening)
49}
50
51fn ensure_area_group<'a>(
52    grouped: &mut GroupedSection<'a>,
53    area_pos: &mut HashMap<ThingsId, usize>,
54    area_uuid: &ThingsId,
55) -> usize {
56    if let Some(i) = area_pos.get(area_uuid).copied() {
57        return i;
58    }
59    let i = grouped.by_area.len();
60    grouped
61        .by_area
62        .push((area_uuid.clone(), AreaGroup::default()));
63    area_pos.insert(area_uuid.clone(), i);
64    i
65}
66
67fn ensure_project_group<'a>(
68    grouped: &mut GroupedSection<'a>,
69    project_pos: &mut HashMap<ThingsId, usize>,
70    project_uuid: &ThingsId,
71) -> usize {
72    if let Some(i) = project_pos.get(project_uuid).copied() {
73        return i;
74    }
75    let i = grouped.by_project.len();
76    grouped.by_project.push((project_uuid.clone(), Vec::new()));
77    project_pos.insert(project_uuid.clone(), i);
78    i
79}
80
81fn group_regular_items<'a>(items: &'a [Task], store: &ThingsStore) -> GroupedSection<'a> {
82    let mut grouped = GroupedSection::default();
83    let mut project_pos: HashMap<ThingsId, usize> = HashMap::new();
84    let mut by_area_pos: HashMap<ThingsId, usize> = HashMap::new();
85
86    for task in items.iter().filter(|task| !task.evening) {
87        if task.is_project() {
88            if let Some(area_uuid) = store.effective_area_uuid(task) {
89                let area_idx = ensure_area_group(&mut grouped, &mut by_area_pos, &area_uuid);
90                grouped.by_area[area_idx].1.tasks.push(task);
91            } else {
92                grouped.unscoped.push(task);
93            }
94            continue;
95        }
96
97        let project_uuid = store.effective_project_uuid(task);
98        let area_uuid = store.effective_area_uuid(task);
99
100        match (project_uuid, area_uuid) {
101            (Some(project_uuid), _) => {
102                let project_idx =
103                    ensure_project_group(&mut grouped, &mut project_pos, &project_uuid);
104                grouped.by_project[project_idx].1.push(task);
105            }
106            (None, Some(area_uuid)) => {
107                let area_idx = ensure_area_group(&mut grouped, &mut by_area_pos, &area_uuid);
108                grouped.by_area[area_idx].1.tasks.push(task);
109            }
110            (None, None) => grouped.unscoped.push(task),
111        }
112    }
113
114    grouped
115}
116
117fn evening_items(items: &[Task]) -> Vec<&Task> {
118    items.iter().filter(|task| task.evening).collect()
119}
120
121fn id_prefix_len(store: &ThingsStore, items: &[Task]) -> usize {
122    let mut ids = items
123        .iter()
124        .map(|task| task.uuid.clone())
125        .collect::<Vec<_>>();
126    for task in items {
127        if let Some(project_uuid) = store.effective_project_uuid(task) {
128            ids.push(project_uuid);
129        }
130        if let Some(area_uuid) = store.effective_area_uuid(task) {
131            ids.push(area_uuid);
132        }
133    }
134    store.unique_prefix_length(&ids)
135}
136
137#[derive(Default, Props)]
138pub struct TodayViewProps<'a> {
139    pub items: Option<&'a Vec<Task>>,
140    pub detailed: bool,
141}
142
143#[component]
144pub fn TodayView<'a>(hooks: Hooks, props: &TodayViewProps<'a>) -> impl Into<AnyElement<'a>> {
145    let store = hooks.use_context::<Arc<ThingsStore>>().clone();
146    let Some(items) = props.items else {
147        return element! { Text(content: "") }.into_any();
148    };
149
150    let content: AnyElement<'a> = {
151        if items.is_empty() {
152            element! { EmptyText(content: "No tasks for today.") }.into_any()
153        } else {
154            let prefix_len = id_prefix_len(store.as_ref(), items);
155            let regular = group_regular_items(items, store.as_ref());
156            let evening = evening_items(items);
157
158            let regular_options = TaskOptions {
159                detailed: props.detailed,
160                show_project: false,
161                show_area: false,
162                show_today_markers: false,
163                show_staged_today_marker: true,
164            };
165            let evening_options = TaskOptions {
166                detailed: props.detailed,
167                show_project: true,
168                show_area: true,
169                show_today_markers: false,
170                show_staged_today_marker: true,
171            };
172
173            let mut regular_blocks: Vec<AnyElement<'a>> = Vec::new();
174
175            if !regular.unscoped.is_empty() {
176                regular_blocks.push(
177                    element! {
178                        TaskGroup(
179                            header: None,
180                            items: regular.unscoped.clone(),
181                            id_prefix_len: prefix_len,
182                            options: regular_options,
183                            indent_under_header: 0u16,
184                            hidden_count: 0usize,
185                        )
186                    }
187                    .into_any(),
188                );
189            }
190
191            for (project_uuid, tasks) in &regular.by_project {
192                regular_blocks.push(
193                    element! {
194                        TaskGroup(
195                            header: Some(TaskGroupHeader::Project {
196                                project_uuid: project_uuid.clone(),
197                                title: store.resolve_project_title(project_uuid),
198                                id_prefix_len: prefix_len,
199                            }),
200                            items: tasks.clone(),
201                            id_prefix_len: prefix_len,
202                            options: regular_options,
203                            indent_under_header: 2u16,
204                            hidden_count: 0usize,
205                        )
206                    }
207                    .into_any(),
208                );
209            }
210
211            for (area_uuid, area_group) in &regular.by_area {
212                regular_blocks.push(
213                    element! {
214                        TaskGroup(
215                            header: Some(TaskGroupHeader::Area {
216                                area_uuid: area_uuid.clone(),
217                                title: store.resolve_area_title(area_uuid),
218                                id_prefix_len: prefix_len,
219                            }),
220                            items: area_group.tasks.clone(),
221                            id_prefix_len: prefix_len,
222                            options: regular_options,
223                            indent_under_header: 2u16,
224                            hidden_count: 0usize,
225                        )
226                    }
227                    .into_any(),
228                );
229            }
230
231            element! {
232                    View(flex_direction: FlexDirection::Column, gap: 1) {
233                        Text(
234                            content: header_text(items),
235                            wrap: TextWrap::NoWrap,
236                            color: Color::Yellow,
237                            weight: Weight::Bold,
238                        )
239
240                        #(if has_regular(items) {
241                            Some(element! {
242                                View(flex_direction: FlexDirection::Column, padding_left: LIST_INDENT, gap: 1) {
243                                    #(regular_blocks)
244                                }
245                            })
246                        } else { None })
247
248                        #(if has_evening(items) {
249                            Some(element! {
250                                View(flex_direction: FlexDirection::Column, gap: 1) {
251                                    Text(
252                                        content: format!("{} This Evening", ICONS.evening),
253                                        wrap: TextWrap::NoWrap,
254                                        color: Color::Blue,
255                                        weight: Weight::Bold,
256                                    )
257                                    View(flex_direction: FlexDirection::Column, padding_left: LIST_INDENT) {
258                                        TaskList(
259                                            items: evening,
260                                            id_prefix_len: prefix_len,
261                                            options: evening_options,
262                                        )
263                                    }
264                                }
265                            })
266                        } else { None })
267                    }
268                }
269                .into_any()
270        }
271    };
272
273    content
274}