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            let mut first = true;
175
176            if !regular.unscoped.is_empty() {
177                regular_blocks.push(
178                    element! {
179                        TaskGroup(
180                            header: None,
181                            items: regular.unscoped.clone(),
182                            id_prefix_len: prefix_len,
183                            options: regular_options,
184                            indent_under_header: 0u16,
185                            hidden_count: 0usize,
186                        )
187                    }
188                    .into_any(),
189                );
190                first = false;
191            }
192
193            for (project_uuid, tasks) in &regular.by_project {
194                if !first {
195                    regular_blocks
196                        .push(element! { Text(content: "", wrap: TextWrap::NoWrap) }.into_any());
197                }
198                regular_blocks.push(
199                    element! {
200                        TaskGroup(
201                            header: Some(TaskGroupHeader::Project {
202                                project_uuid: project_uuid.clone(),
203                                title: store.resolve_project_title(project_uuid),
204                                id_prefix_len: prefix_len,
205                            }),
206                            items: tasks.clone(),
207                            id_prefix_len: prefix_len,
208                            options: regular_options,
209                            indent_under_header: 2u16,
210                            hidden_count: 0usize,
211                        )
212                    }
213                    .into_any(),
214                );
215                first = false;
216            }
217
218            for (area_uuid, area_group) in &regular.by_area {
219                if !first {
220                    regular_blocks
221                        .push(element! { Text(content: "", wrap: TextWrap::NoWrap) }.into_any());
222                }
223                regular_blocks.push(
224                    element! {
225                        TaskGroup(
226                            header: Some(TaskGroupHeader::Area {
227                                area_uuid: area_uuid.clone(),
228                                title: store.resolve_area_title(area_uuid),
229                                id_prefix_len: prefix_len,
230                            }),
231                            items: area_group.tasks.clone(),
232                            id_prefix_len: prefix_len,
233                            options: regular_options,
234                            indent_under_header: 2u16,
235                            hidden_count: 0usize,
236                        )
237                    }
238                    .into_any(),
239                );
240                first = false;
241            }
242
243            element! {
244                    View(flex_direction: FlexDirection::Column) {
245                        Text(
246                            content: header_text(items),
247                            wrap: TextWrap::NoWrap,
248                            color: Color::Yellow,
249                            weight: Weight::Bold,
250                        )
251
252                        #(if has_regular(items) {
253                            Some(element! {
254                                View(flex_direction: FlexDirection::Column) {
255                                    Text(content: "", wrap: TextWrap::NoWrap)
256                                    View(flex_direction: FlexDirection::Column, padding_left: LIST_INDENT) {
257                                        #(regular_blocks)
258                                    }
259                                }
260                            })
261                        } else { None })
262
263                        #(if has_evening(items) {
264                            Some(element! {
265                                View(flex_direction: FlexDirection::Column) {
266                                    Text(content: "", wrap: TextWrap::NoWrap)
267                                    Text(
268                                        content: format!("{} This Evening", ICONS.evening),
269                                        wrap: TextWrap::NoWrap,
270                                        color: Color::Blue,
271                                        weight: Weight::Bold,
272                                    )
273                                    Text(content: "", wrap: TextWrap::NoWrap)
274                                    View(flex_direction: FlexDirection::Column, padding_left: LIST_INDENT) {
275                                        TaskList(
276                                            items: evening,
277                                            id_prefix_len: prefix_len,
278                                            options: evening_options,
279                                        )
280                                    }
281                                }
282                            })
283                        } else { None })
284                    }
285                }
286                .into_any()
287        }
288    };
289
290    content
291}