Skip to main content

things3_cloud/ui/views/
today.rs

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