Skip to main content

todoapp_app/
aggregate.rs

1//! Per-capability subtree roll-ups (FR-13): walk `child` links from a task over
2//! itself + all descendants. Status → progress % + done/total, TimeSpent →
3//! sum, Estimate → total sum + `remaining` (non-`Done` tasks only), Schedule →
4//! earliest due, Assignments → union of assignees (spec §13 Q3 default:
5//! progress = done/total).
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use todoapp_core::{
10    Assignments, ComponentStore, Due, Duration, Estimate, Id, Schedule, Status, TaskEntityStore,
11    TimeSpent,
12};
13
14use crate::service::{Error, Services};
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct Aggregate {
18    pub total: usize,
19    pub done: usize,
20    pub progress: f32,
21    pub time_spent: Duration,
22    pub estimate: Duration,
23    /// `Estimate` summed over tasks whose `Status` is not `Done` — the TUI's
24    /// eta projection input (spec: no partial credit for `TimeSpent`).
25    pub remaining: Duration,
26    pub earliest_due: Option<Due>,
27    pub assignees: BTreeSet<Id>,
28    /// Worst-case (lowest-`rank`) `Status` over the task + its descendants —
29    /// only `Done` when every task in the subtree is `Done`.
30    pub status: Status,
31    /// Count of subtree tasks at each `Status`.
32    pub by_status: BTreeMap<Status, usize>,
33}
34
35impl Default for Aggregate {
36    fn default() -> Self {
37        Self {
38            total: 0,
39            done: 0,
40            progress: 0.0,
41            time_spent: Duration::ZERO,
42            estimate: Duration::ZERO,
43            remaining: Duration::ZERO,
44            earliest_due: None,
45            assignees: BTreeSet::new(),
46            status: Status::Draft,
47            by_status: BTreeMap::new(),
48        }
49    }
50}
51
52impl<'a, St: ComponentStore + TaskEntityStore> Services<'a, St> {
53    pub async fn aggregate(&self, id: &Id) -> Result<Aggregate, Error> {
54        let mut agg = Aggregate::default();
55        // Roll up over `id` + descendants. Iterative (not recursive `fold`) to
56        // avoid boxing an async recursion; the roll-ups are order-independent.
57        // Each capability reads only its own component (spec §3 per-cap roll-up).
58        let mut ids = self.descendants(id).await;
59        ids.insert(id.clone());
60        let mut worst: Option<Status> = None;
61        for tid in ids {
62            agg.total += 1;
63            let status = self.store.get::<Status>(&tid).await;
64            if status == Some(Status::Done) {
65                agg.done += 1;
66            }
67            let status = status.unwrap_or(Status::Draft);
68            *agg.by_status.entry(status).or_insert(0) += 1;
69            worst = Some(match worst {
70                Some(w) if w.rank() <= status.rank() => w,
71                _ => status,
72            });
73            agg.time_spent += self
74                .store
75                .get::<TimeSpent>(&tid)
76                .await
77                .map_or(Duration::ZERO, |t| t.0);
78            let estimate = self
79                .store
80                .get::<Estimate>(&tid)
81                .await
82                .map_or(Duration::ZERO, |e| e.0);
83            agg.estimate += estimate;
84            if status != Status::Done {
85                agg.remaining += estimate;
86            }
87            if let Some(Schedule(due)) = self.store.get::<Schedule>(&tid).await {
88                agg.earliest_due = Some(match agg.earliest_due.take() {
89                    Some(cur) if cur <= due => cur,
90                    _ => due,
91                });
92            }
93            if let Some(Assignments(asg)) = self.store.get::<Assignments>(&tid).await {
94                agg.assignees.extend(asg.into_iter().map(|a| a.actor));
95            }
96        }
97        agg.progress = if agg.total > 0 {
98            agg.done as f32 / agg.total as f32
99        } else {
100            0.0
101        };
102        agg.status = worst.unwrap_or(Status::Draft);
103        Ok(agg)
104    }
105}