Skip to main content

todoapp_core/
model.rs

1//! Entities, capability components, and value objects (spec §3, §7).
2//!
3//! Storage is one component per capability (spec §7): the durable `task` entity
4//! is just identity + timestamps, and each capability is a separate component
5//! whose *presence* means the task has it. [`TaskState`] is the in-memory
6//! *aggregate* — a task assembled from the components a caller projected (see
7//! [`crate::Projection`]) — and is what `decide`/`apply` operate on.
8
9use jiff::ToSpan;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13
14use crate::temporal::{Date, Due, Duration, Time};
15
16/// Stable identity for tasks, actors, collections. Opaque string (a random ULID
17/// in real adapters; a sequence in tests). Serializes transparently as that
18/// string.
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20pub struct Id(pub String);
21
22impl Id {
23    pub fn new(s: impl Into<String>) -> Self {
24        Self(s.into())
25    }
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29    /// The invisible structural root (spec §7 virtual-root sentinel). Never a
30    /// `task` entity — only ever a `child` link `from`. The reserved string
31    /// can't collide with a 26-char base32 ULID.
32    pub fn root() -> Self {
33        Self("__root__".into())
34    }
35    pub fn is_root(&self) -> bool {
36        self.0 == "__root__"
37    }
38    /// Content-addressed id for a blob: same bytes ⇒ same id (cheap incidental
39    /// dedup, not a content-hash identity guarantee — collisions are possible
40    /// but not a practical concern at this scale). Shared by every `BlobStore`
41    /// adapter so they agree on ids for the same bytes.
42    pub fn for_blob(bytes: &[u8]) -> Self {
43        use std::collections::hash_map::DefaultHasher;
44        use std::hash::{Hash, Hasher};
45        let mut h = DefaultHasher::new();
46        bytes.hash(&mut h);
47        Self(format!("blob_{:016x}", h.finish()))
48    }
49}
50
51impl fmt::Display for Id {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(&self.0)
54    }
55}
56
57/// Required `Status` capability (spec §8). `blocked` is *derived*, not stored.
58/// Transitions between any two values are unrestricted (no guard) — `rank` is
59/// just for ordering/display, not a legality check.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Status {
63    Draft,
64    Todo,
65    Wip,
66    Paused,
67    Done,
68}
69
70impl Status {
71    /// Position in the `draft→todo→wip→paused→done` chain, for ordering/display only.
72    pub fn rank(self) -> i8 {
73        match self {
74            Status::Draft => 0,
75            Status::Todo => 1,
76            Status::Wip => 2,
77            Status::Paused => 3,
78            Status::Done => 4,
79        }
80    }
81}
82
83impl fmt::Display for Status {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        let s = match self {
86            Status::Draft => "draft",
87            Status::Todo => "todo",
88            Status::Wip => "wip",
89            Status::Paused => "paused",
90            Status::Done => "done",
91        };
92        f.write_str(s)
93    }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum ActorKind {
99    Person,
100    Agent,
101}
102
103/// A human or agent. Not persisted via a port in M1 (the spec lists no
104/// `ActorRepository`); `Assignment`/`Claim` only ever reference an actor `Id`.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct Actor {
107    pub id: Id,
108    pub kind: ActorKind,
109    pub name: String,
110}
111
112/// One assignee on a task; `claimed` flips when that actor claims it (§8).
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct Assignment {
115    pub actor: Id,
116    pub claimed: bool,
117}
118
119/// A capability component (spec §3): a unit of data keyed by task `Id` in the
120/// store. **Presence of the value *is* the capability** — there is no monolithic
121/// `Task` struct; a task is the set of components attached to its id, fetched and
122/// mutated one capability at a time (`store.get::<Status>(id)` /
123/// `store.set(id, Status::Wip)`). `NAME` keys the per-capability map/table
124/// (spec §7). Adding a capability = a new `Component` type; the generic store
125/// needs no change.
126///
127/// The in-memory store only needs `Clone + 'static` (typed `Box<dyn Any>`); the
128/// serde bounds are for durable stores that map a component to its row(s).
129///
130/// The `Serialize`/`DeserializeOwned` bound lets a store map a component
131/// generically to/from its row(s): the Turso adapter (M2) bridges each value
132/// through `serde_json::to_value`/`from_value` to its typed `c_*` column(s).
133pub trait Component: Clone + 'static + Serialize + serde::de::DeserializeOwned {
134    const NAME: &'static str;
135}
136
137/// Required `Title` capability.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct Title(pub String);
140impl Component for Title {
141    const NAME: &'static str = "title";
142}
143
144/// Required `Status` capability (the enum is the component value itself).
145impl Component for Status {
146    const NAME: &'static str = "status";
147}
148
149/// `Notes` capability: Markdown body.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct Notes(pub String);
152impl Component for Notes {
153    const NAME: &'static str = "notes";
154}
155
156/// `Schedule` capability: a due date, optionally with a time-of-day.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158pub struct Schedule(pub Due);
159impl Component for Schedule {
160    const NAME: &'static str = "schedule";
161}
162
163/// `Estimate` capability (effort estimate).
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165pub struct Estimate(pub Duration);
166impl Component for Estimate {
167    const NAME: &'static str = "estimate";
168}
169
170/// `TimeSpent` capability (accumulated time).
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172pub struct TimeSpent(pub Duration);
173impl Component for TimeSpent {
174    const NAME: &'static str = "timespent";
175}
176
177/// `TimeLog` capability: a per-day breakdown of time spent, keyed by calendar
178/// date. `TimeSpent` stays the fast-path cumulative total — it's recomputed
179/// as this map's sum whenever it's set (see the `Event::TimeLogSet` apply
180/// arm), so aggregation (FR-13) keeps reading `TimeSpent` unchanged. Mixing
181/// this with the plain `AddTimeSpent` command (no date) can leave the two
182/// slightly inconsistent — a known, accepted edge case, not guarded against.
183#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
184pub struct TimeLog(pub BTreeMap<Date, Duration>);
185impl Component for TimeLog {
186    const NAME: &'static str = "timelog";
187}
188
189/// `Tags` capability: the whole set is one component value (empty ⇒ remove it).
190#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Tags(pub BTreeSet<String>);
192impl Component for Tags {
193    const NAME: &'static str = "tags";
194}
195
196/// `Assignment` capability: the whole assignee list is one component value
197/// (empty ⇒ remove it). Its presence/contents drive `Claim` (spec §8).
198#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
199pub struct Assignments(pub Vec<Assignment>);
200impl Component for Assignments {
201    const NAME: &'static str = "assignments";
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum AttachmentKind {
207    Link,
208    File,
209    Image,
210}
211
212/// One attachment: a `Link` never has a `blob` (it's just a URL); `File`/
213/// `Image` may or may not have one — `url` keeps the original source
214/// path/URL either way (e.g. from an import), `blob` is `Some` once actual
215/// bytes have been stored via [`crate::BlobStore`].
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct Attachment {
218    pub id: Id,
219    pub kind: AttachmentKind,
220    pub title: String,
221    pub url: Option<String>,
222    pub blob: Option<Id>,
223    pub mime: Option<String>,
224}
225
226/// `Attachments` capability: the whole list is one component value (empty ⇒
227/// remove it), like `Tags`/`Assignments`.
228#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct Attachments(pub Vec<Attachment>);
230impl Component for Attachments {
231    const NAME: &'static str = "attachments";
232}
233
234/// `Archived` capability: an orthogonal flag, independent of `Status` (a task
235/// can be `done` and archived, or archived without being `done`) — presence
236/// *is* the flag, no payload needed. Hidden from default views by callers
237/// passing `Filter { archived: Some(false), .. }` (spec §13 Q4 direction);
238/// `QueryEngine`/`Filter` itself stay neutral (`None` = no restriction).
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240pub struct Archived;
241impl Component for Archived {
242    const NAME: &'static str = "archived";
243}
244
245/// `IssueRef` capability: a static reference to an external issue tracker's
246/// issue (e.g. imported from another tool). `provider` is freeform (no closed
247/// enum) — no live sync, no computed URL.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct IssueRef {
250    pub provider: String,
251    pub id: String,
252    pub url: Option<String>,
253}
254impl Component for IssueRef {
255    const NAME: &'static str = "issueref";
256}
257
258/// A day of the week, for [`RepeatCycle::Weekly`]. A local enum (not jiff's)
259/// so serde stays as simple as [`Status`]'s.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
261#[serde(rename_all = "lowercase")]
262pub enum Weekday {
263    Mon,
264    Tue,
265    Wed,
266    Thu,
267    Fri,
268    Sat,
269    Sun,
270}
271
272impl Weekday {
273    fn from_jiff(w: jiff::civil::Weekday) -> Self {
274        match w.to_monday_zero_offset() {
275            0 => Weekday::Mon,
276            1 => Weekday::Tue,
277            2 => Weekday::Wed,
278            3 => Weekday::Thu,
279            4 => Weekday::Fri,
280            5 => Weekday::Sat,
281            _ => Weekday::Sun,
282        }
283    }
284}
285
286/// A recurrence rule (spec §3): how often a [`Recurrence`]-carrying task's due
287/// date advances when it's completed (see `Recurrence::next_due`). Covers the
288/// common cases (daily interval, weekly weekday set, monthly same-day) — not a
289/// full RRULE engine.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "lowercase")]
292pub enum RepeatCycle {
293    Daily { every_n_days: u32 },
294    Weekly { weekdays: BTreeSet<Weekday> },
295    Monthly { every_n_months: u32 },
296}
297
298/// `Recurrence` capability: a task carrying this **resets in place** on
299/// completion instead of staying `done` — spec decision: no per-occurrence
300/// task spawning, the same task's `Schedule` advances and its `Status` goes
301/// back to `todo` (see the `Event::StatusSet(Status::Done)` apply arm).
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct Recurrence {
304    pub cycle: RepeatCycle,
305    /// Time-of-day to carry onto the recomputed due date; falls back to the
306    /// current due's time if unset.
307    pub time: Option<Time>,
308}
309impl Component for Recurrence {
310    const NAME: &'static str = "recurrence";
311}
312
313impl Recurrence {
314    /// The next due date/time after `current`, per this rule.
315    pub fn next_due(&self, current: Due) -> Due {
316        let date = match &self.cycle {
317            RepeatCycle::Daily { every_n_days } => {
318                let n = i64::from((*every_n_days).max(1));
319                current
320                    .date
321                    .0
322                    .checked_add(n.days())
323                    .map(Date)
324                    .unwrap_or(current.date)
325            }
326            RepeatCycle::Weekly { weekdays } => next_weekday(current.date, weekdays),
327            RepeatCycle::Monthly { every_n_months } => {
328                let n = i64::from((*every_n_months).max(1));
329                current
330                    .date
331                    .0
332                    .checked_add(n.months())
333                    .map(Date)
334                    .unwrap_or(current.date)
335            }
336        };
337        Due {
338            date,
339            time: self.time.or(current.time),
340        }
341    }
342}
343
344/// The next date after `from` whose weekday is in `weekdays` (or, if empty,
345/// just the next day — an under-specified rule still advances).
346fn next_weekday(from: Date, weekdays: &BTreeSet<Weekday>) -> Date {
347    let mut d = from.0;
348    for _ in 0..7 {
349        d = d.checked_add(1.day()).unwrap_or(d);
350        if weekdays.is_empty() || weekdays.contains(&Weekday::from_jiff(d.weekday())) {
351            return Date(d);
352        }
353    }
354    from
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "lowercase")]
359pub enum LinkKind {
360    Child,
361    Blocks,
362}
363
364/// Fractional index (spec §7): insert between two neighbours by averaging, so a
365/// reorder or subtree move touches one row, never the siblings.
366#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
367pub struct Position(pub f64);
368
369impl Position {
370    /// A position strictly between `before` and `after` (either may be open).
371    pub fn between(before: Option<f64>, after: Option<f64>) -> f64 {
372        match (before, after) {
373            (None, None) => 0.0,
374            (Some(b), None) => b + 1.0,
375            (None, Some(a)) => a - 1.0,
376            (Some(b), Some(a)) => (b + a) / 2.0,
377        }
378    }
379}
380
381/// A typed, ordered directed edge. `child` is a single-parent tree; `blocks` is
382/// a DAG (invariants enforced in `todoapp-app`).
383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
384pub struct Link {
385    pub from: Id,
386    pub to: Id,
387    pub kind: LinkKind,
388    pub position: Position,
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "lowercase")]
393pub enum CollectionKind {
394    Tree,
395    Query,
396}
397
398/// A saved tree or saved query (spec §7). `spec` holds the query for `query` kind.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400pub struct Collection {
401    pub id: Id,
402    pub name: String,
403    pub kind: CollectionKind,
404    pub spec: Option<Query>,
405}
406
407// ---- Query model (spec §7 "Query model") ----------------------------------
408
409#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
410pub struct Query {
411    #[serde(default)]
412    pub filter: Filter,
413    #[serde(default)]
414    pub sort: Vec<SortKey>,
415}
416
417#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
418pub struct Filter {
419    pub text: Option<String>,
420    #[serde(default)]
421    pub status: Vec<Status>,
422    pub assignee: Option<Id>,
423    /// all-of (spec §13 default).
424    #[serde(default)]
425    pub tags: Vec<String>,
426    pub within: Option<Id>,
427    pub due: Option<DueFilter>,
428    pub claimed: Option<bool>,
429    /// `None` = no restriction (matches archived and non-archived alike);
430    /// hiding archived tasks by default is a caller-side choice, not a
431    /// query-engine special case.
432    pub archived: Option<bool>,
433}
434
435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
436pub enum DueFilter {
437    Today,
438    Overdue,
439    Before(Date),
440    On(Date),
441    After(Date),
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
445#[serde(rename_all = "lowercase")]
446pub enum SortField {
447    Priority,
448    Due,
449    Created,
450    Updated,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
454#[serde(rename_all = "lowercase")]
455pub enum Dir {
456    Asc,
457    Desc,
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
461pub struct SortKey {
462    pub key: SortField,
463    pub dir: Dir,
464}
465
466#[cfg(test)]
467mod recurrence_tests {
468    use super::*;
469
470    fn due(s: &str) -> Due {
471        Due::parse(s).unwrap()
472    }
473
474    #[test]
475    fn daily_advances_by_n_days() {
476        let rec = Recurrence {
477            cycle: RepeatCycle::Daily { every_n_days: 3 },
478            time: None,
479        };
480        assert_eq!(rec.next_due(due("2026-07-01")).date, due("2026-07-04").date);
481    }
482
483    #[test]
484    fn weekly_finds_next_matching_weekday() {
485        // 2026-07-01 is a Wednesday; next Mon/Wed/Fri after it is Friday.
486        let rec = Recurrence {
487            cycle: RepeatCycle::Weekly {
488                weekdays: BTreeSet::from([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
489            },
490            time: None,
491        };
492        assert_eq!(rec.next_due(due("2026-07-01")).date, due("2026-07-03").date);
493    }
494
495    #[test]
496    fn monthly_advances_by_n_months_same_day() {
497        let rec = Recurrence {
498            cycle: RepeatCycle::Monthly { every_n_months: 1 },
499            time: None,
500        };
501        assert_eq!(rec.next_due(due("2026-07-15")).date, due("2026-08-15").date);
502    }
503
504    #[test]
505    fn recurrence_time_wins_over_carried_time() {
506        let rec = Recurrence {
507            cycle: RepeatCycle::Daily { every_n_days: 1 },
508            time: Some(Time::parse("09:00").unwrap()),
509        };
510        let next = rec.next_due(due("2026-07-01 18:00"));
511        assert_eq!(next.time, Some(Time::parse("09:00").unwrap()));
512    }
513}