Skip to main content

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