1use jiff::ToSpan;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13
14use crate::temporal::{Date, Due, Duration, Time};
15
16#[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 pub fn root() -> Self {
33 Self("__root__".into())
34 }
35 pub fn is_root(&self) -> bool {
36 self.0 == "__root__"
37 }
38 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#[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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct Assignment {
115 pub actor: Id,
116 pub claimed: bool,
117}
118
119pub trait Component: Clone + 'static + Serialize + serde::de::DeserializeOwned {
134 const NAME: &'static str;
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct Title(pub String);
140impl Component for Title {
141 const NAME: &'static str = "title";
142}
143
144impl Component for Status {
146 const NAME: &'static str = "status";
147}
148
149#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240pub struct Archived;
241impl Component for Archived {
242 const NAME: &'static str = "archived";
243}
244
245#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct Recurrence {
304 pub cycle: RepeatCycle,
305 pub time: Option<Time>,
308}
309impl Component for Recurrence {
310 const NAME: &'static str = "recurrence";
311}
312
313impl Recurrence {
314 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
344fn 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#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
367pub struct Position(pub f64);
368
369impl Position {
370 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#[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#[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#[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 #[serde(default)]
425 pub tags: Vec<String>,
426 pub within: Option<Id>,
427 pub due: Option<DueFilter>,
428 pub claimed: Option<bool>,
429 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 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}