use jiff::ToSpan;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use crate::temporal::{Date, Due, Duration, Time};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Id(pub String);
impl Id {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn root() -> Self {
Self("__root__".into())
}
pub fn is_root(&self) -> bool {
self.0 == "__root__"
}
pub fn for_blob(bytes: &[u8]) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
bytes.hash(&mut h);
Self(format!("blob_{:016x}", h.finish()))
}
}
impl fmt::Display for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Draft,
Todo,
Wip,
Paused,
Done,
}
impl Status {
pub fn rank(self) -> i8 {
match self {
Status::Draft => 0,
Status::Todo => 1,
Status::Wip => 2,
Status::Paused => 3,
Status::Done => 4,
}
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Status::Draft => "draft",
Status::Todo => "todo",
Status::Wip => "wip",
Status::Paused => "paused",
Status::Done => "done",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ActorKind {
Person,
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Actor {
pub id: Id,
pub kind: ActorKind,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Assignment {
pub actor: Id,
pub claimed: bool,
}
pub trait Component: Clone + 'static + Serialize + serde::de::DeserializeOwned {
const NAME: &'static str;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Title(pub String);
impl Component for Title {
const NAME: &'static str = "title";
}
impl Component for Status {
const NAME: &'static str = "status";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Notes(pub String);
impl Component for Notes {
const NAME: &'static str = "notes";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Schedule(pub Due);
impl Component for Schedule {
const NAME: &'static str = "schedule";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Estimate(pub Duration);
impl Component for Estimate {
const NAME: &'static str = "estimate";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeSpent(pub Duration);
impl Component for TimeSpent {
const NAME: &'static str = "timespent";
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeLog(pub BTreeMap<Date, Duration>);
impl Component for TimeLog {
const NAME: &'static str = "timelog";
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tags(pub BTreeSet<String>);
impl Component for Tags {
const NAME: &'static str = "tags";
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Assignments(pub Vec<Assignment>);
impl Component for Assignments {
const NAME: &'static str = "assignments";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AttachmentKind {
Link,
File,
Image,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attachment {
pub id: Id,
pub kind: AttachmentKind,
pub title: String,
pub url: Option<String>,
pub blob: Option<Id>,
pub mime: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attachments(pub Vec<Attachment>);
impl Component for Attachments {
const NAME: &'static str = "attachments";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Archived;
impl Component for Archived {
const NAME: &'static str = "archived";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssueRef {
pub provider: String,
pub id: String,
pub url: Option<String>,
}
impl Component for IssueRef {
const NAME: &'static str = "issueref";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Weekday {
Mon,
Tue,
Wed,
Thu,
Fri,
Sat,
Sun,
}
impl Weekday {
fn from_jiff(w: jiff::civil::Weekday) -> Self {
match w.to_monday_zero_offset() {
0 => Weekday::Mon,
1 => Weekday::Tue,
2 => Weekday::Wed,
3 => Weekday::Thu,
4 => Weekday::Fri,
5 => Weekday::Sat,
_ => Weekday::Sun,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RepeatCycle {
Daily { every_n_days: u32 },
Weekly { weekdays: BTreeSet<Weekday> },
Monthly { every_n_months: u32 },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recurrence {
pub cycle: RepeatCycle,
pub time: Option<Time>,
}
impl Component for Recurrence {
const NAME: &'static str = "recurrence";
}
impl Recurrence {
pub fn next_due(&self, current: Due) -> Due {
let date = match &self.cycle {
RepeatCycle::Daily { every_n_days } => {
let n = i64::from((*every_n_days).max(1));
current
.date
.0
.checked_add(n.days())
.map(Date)
.unwrap_or(current.date)
}
RepeatCycle::Weekly { weekdays } => next_weekday(current.date, weekdays),
RepeatCycle::Monthly { every_n_months } => {
let n = i64::from((*every_n_months).max(1));
current
.date
.0
.checked_add(n.months())
.map(Date)
.unwrap_or(current.date)
}
};
Due {
date,
time: self.time.or(current.time),
}
}
}
fn next_weekday(from: Date, weekdays: &BTreeSet<Weekday>) -> Date {
let mut d = from.0;
for _ in 0..7 {
d = d.checked_add(1.day()).unwrap_or(d);
if weekdays.is_empty() || weekdays.contains(&Weekday::from_jiff(d.weekday())) {
return Date(d);
}
}
from
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LinkKind {
Child,
Blocks,
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct Position(pub f64);
impl Position {
pub fn between(before: Option<f64>, after: Option<f64>) -> f64 {
match (before, after) {
(None, None) => 0.0,
(Some(b), None) => b + 1.0,
(None, Some(a)) => a - 1.0,
(Some(b), Some(a)) => (b + a) / 2.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Link {
pub from: Id,
pub to: Id,
pub kind: LinkKind,
pub position: Position,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CollectionKind {
Tree,
Query,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Collection {
pub id: Id,
pub name: String,
pub kind: CollectionKind,
pub spec: Option<Query>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Query {
#[serde(default)]
pub filter: Filter,
#[serde(default)]
pub sort: Vec<SortKey>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Filter {
pub text: Option<String>,
#[serde(default)]
pub status: Vec<Status>,
pub assignee: Option<Id>,
#[serde(default)]
pub tags: Vec<String>,
pub within: Option<Id>,
pub due: Option<DueFilter>,
pub claimed: Option<bool>,
pub archived: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DueFilter {
Today,
Overdue,
Before(Date),
On(Date),
After(Date),
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortField {
Priority,
Due,
Created,
Updated,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Dir {
Asc,
Desc,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SortKey {
pub key: SortField,
pub dir: Dir,
}
#[cfg(test)]
mod recurrence_tests {
use super::*;
fn due(s: &str) -> Due {
Due::parse(s).unwrap()
}
#[test]
fn daily_advances_by_n_days() {
let rec = Recurrence {
cycle: RepeatCycle::Daily { every_n_days: 3 },
time: None,
};
assert_eq!(rec.next_due(due("2026-07-01")).date, due("2026-07-04").date);
}
#[test]
fn weekly_finds_next_matching_weekday() {
let rec = Recurrence {
cycle: RepeatCycle::Weekly {
weekdays: BTreeSet::from([Weekday::Mon, Weekday::Wed, Weekday::Fri]),
},
time: None,
};
assert_eq!(rec.next_due(due("2026-07-01")).date, due("2026-07-03").date);
}
#[test]
fn monthly_advances_by_n_months_same_day() {
let rec = Recurrence {
cycle: RepeatCycle::Monthly { every_n_months: 1 },
time: None,
};
assert_eq!(rec.next_due(due("2026-07-15")).date, due("2026-08-15").date);
}
#[test]
fn recurrence_time_wins_over_carried_time() {
let rec = Recurrence {
cycle: RepeatCycle::Daily { every_n_days: 1 },
time: Some(Time::parse("09:00").unwrap()),
};
let next = rec.next_due(due("2026-07-01 18:00"));
assert_eq!(next.time, Some(Time::parse("09:00").unwrap()));
}
}