From 1c6c51d88a73e5884b2c4873d39564ef2303b7ba Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Tue, 28 Apr 2026 21:32:33 -0700 Subject: [PATCH 01/10] proof of --- Cargo.lock | 48 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/task/status.rs | 3 +++ src/task/task.rs | 29 +++++++++++++++++++++++++++- 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ef6b1b28e..83d310d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,6 +550,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1689,6 +1699,24 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2042,6 +2070,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rrule" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74" +dependencies = [ + "chrono", + "chrono-tz", + "log", + "regex", + "thiserror 2.0.18", +] + [[package]] name = "rstest" version = "0.26.1" @@ -2350,6 +2391,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -2487,6 +2534,7 @@ dependencies = [ "reqwest", "reqwest-middleware", "ring", + "rrule", "rstest", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5400909f7..447addaef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ tokio = { version = "1", features = ["macros", "sync", "rt"] } thiserror = "2.0" uuid = { version = "^1.23.0", features = ["serde", "v4"] } url = { version = "2", optional = true } +rrule = "0.14.0" ## wasm-only dependencies. [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/task/status.rs b/src/task/status.rs index 38d017df7..db5d8a225 100644 --- a/src/task/status.rs +++ b/src/task/status.rs @@ -6,6 +6,7 @@ pub enum Status { Completed, Deleted, Recurring, + Iterative, /// Unknown signifies a status in the task DB that was not /// recognized. This supports forward-compatibility if a /// new status is added. Tasks with unknown status should @@ -21,6 +22,7 @@ impl Status { "completed" => Status::Completed, "deleted" => Status::Deleted, "recurring" => Status::Recurring, + "iterative" => Status::Iterative, v => Status::Unknown(v.to_string()), } } @@ -32,6 +34,7 @@ impl Status { Status::Completed => "completed", Status::Deleted => "deleted", Status::Recurring => "recurring", + Status::Iterative => "iterative", Status::Unknown(v) => v.as_ref(), } } diff --git a/src/task/task.rs b/src/task/task.rs index 599b90240..6cad0837c 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -6,6 +6,7 @@ use crate::storage::TaskMap; use crate::{Operations, TaskData}; use chrono::prelude::*; use log::trace; +use rrule::Tz; use std::convert::AsRef; use std::convert::TryInto; use std::str::FromStr; @@ -317,7 +318,33 @@ impl Task { self.set_timestamp(Prop::End.as_ref(), Some(Utc::now()), ops)?; } } - _ => {} + Status::Iterative => { + // Check that there is a an 'iter' value. + if let Some(iter) = self.data.get("iter") { + // calculate dates, etc + // For the proof of concept, we'll assume that the rrule dt_start + // time is now. In the future this could be parsed from 'iter'. + let dt_start = chrono::Utc::now().with_timezone(&Tz::Local(chrono::Local)); + // Create the rrule. For the proof of concept, we'll assume + // that 'iter' is an rrule. In the future, we'd parse 'iter' + // into one. + let rule: rrule::RRule = iter.parse().map_err(|e| { + Error::Usage(format!("Couldn't parse iter into rrule: {e}")) + })?; + let rule = rule + .validate(dt_start) + .map_err(|e| Error::Usage(format!("Couldn't validate rrule: {e}")))? + .to_string(); + // Set the rrule value. + self.set_value("rrule", Some(rule), ops)?; + // Set the next due date. + } else { + return Err(Error::Usage( + "Iterative tasks require an 'iter' value.".into(), + )); + } + } + Status::Unknown(_) => {} } self.set_value( Prop::Status.as_ref(), From 1941f4bbe260704f49edb0a15ae44a3ad4a84cff Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Mon, 4 May 2026 18:27:46 -0700 Subject: [PATCH 02/10] iter task creation working, with str2rrule --- src/lib.rs | 2 +- src/task/iter.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++++++ src/task/mod.rs | 2 + src/task/task.rs | 35 +++++++++++------ 4 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 src/task/iter.rs diff --git a/src/lib.rs b/src/lib.rs index 1862c5172..7df1f5c5c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ pub use server::{Server, ServerConfig}; pub use storage::indexeddb::IndexedDbStorage; #[cfg(feature = "storage-sqlite")] pub use storage::sqlite::SqliteStorage; -pub use task::{utc_timestamp, Annotation, Status, Tag, Task, TaskData}; +pub use task::{str2rrule, utc_timestamp, Annotation, IterType, Status, Tag, Task, TaskData}; pub use workingset::WorkingSet; /// Re-exported type from the `uuid` crate, for ease of compatibility for consumers of this crate. diff --git a/src/task/iter.rs b/src/task/iter.rs new file mode 100644 index 000000000..5a85e7f1b --- /dev/null +++ b/src/task/iter.rs @@ -0,0 +1,99 @@ +use crate::errors::{Error, Result}; +use rrule::{Frequency, NWeekday, RRule, Unvalidated, Weekday}; +use strum_macros::{Display, EnumString}; +/// The iteration type of a task. +#[derive(Debug, PartialEq, Eq, Clone, Display, EnumString)] +#[repr(C)] +pub enum IterType { + #[strum(serialize = "fixed", serialize = "fx")] + Fixed, + #[strum(serialize = "fixed+", serialize = "f+", serialize = "fp")] + FixedPlus, + #[strum(serialize = "chained", serialize = "ch")] + Chained, + /// Unknown signifies an iter type in the task DB that was not + /// recognized. This supports forward-compatibility if a + /// new type is added. Tasks with unknown iter types should + /// be ignored (but not deleted). + Unknown(String), +} + +enum SpecialDays { + Weekday, + Weekend, +} +/// Converts an iteration description string to a RRule. +/// +/// For now, only handles the standard TaskWarrior style descriptions. +pub fn str2rrule(value: &str) -> Result> { + // Most TW iteration strings are of the form: + // nPP where n is the interval number and PP is the period. + // e.g. 3wks -> every three weeks. + // If n is missing, it is assumed to be 1. + // Steps: + // 1) Normalize string (2WeEKs -> 2weeks) + // 2) Expand special terms (annual -> 1year, fortnight -> 2week) + // 2) Look for interval number (2week -> (2, week), mo -> (1, mo)) + // 3) Parse intervals into tokens (wk -> Week) + // 4) Generate RRule ( (2, week) -> FREQ=WEEKLY;INTERVAL=2) + + // Normalize. + let value = value.trim().to_ascii_lowercase(); + + // Special terms. + let value = match value.as_str() { + "fortnight" | "fortnightly" | "biweekly" => "2week", + "semiannual" => "6month", + "annual" => "1year", + "biannual" | "biannually" | "biyearly" | "biyear" => "2year", + _ => value.as_str(), + }; + + // Split into interval and period. + let num_str: String = value.chars().take_while(|c| c.is_ascii_digit()).collect(); + let mut interval = num_str.parse::().unwrap_or(1); + let period = &value[num_str.len()..]; + + // Parse period into enum. + let mut special_days: Option = None; + let freq = match period { + "se" | "sec" | "second" | "seconds" | "secondly" => Frequency::Secondly, + "mi" | "min" | "minute" | "minutes" | "minutley" => Frequency::Minutely, + "hr" | "hour" | "hours" | "hourly" => Frequency::Hourly, + "day" | "days" | "daily" => Frequency::Daily, + "wk" | "week" | "weekly" | "wkly" => Frequency::Weekly, + "wkd" | "weekday" | "weekdays" | "weekdaily" => { + special_days = Some(SpecialDays::Weekday); + Frequency::Daily + } + "wknd" | "weekend" | "weekends" | "weekendly" => { + special_days = Some(SpecialDays::Weekend); + Frequency::Daily + } + "mo" | "month" | "months" | "monthly" => Frequency::Monthly, + "qtr" | "qtrs" | "quarter" | "quarterly" => { + interval *= 3; + Frequency::Monthly + } + "yr" | "year" | "yearly" | "annual" => Frequency::Yearly, + _ => return Err(Error::Usage(format!("Could not parse period {}.", period))), + }; + + // Generate the RRule. + let rule = RRule::new(freq).interval(interval); + let rule = match special_days { + None => rule, + Some(SpecialDays::Weekday) => rule.by_weekday(vec![ + NWeekday::Every(Weekday::Mon), + NWeekday::Every(Weekday::Tue), + NWeekday::Every(Weekday::Wed), + NWeekday::Every(Weekday::Thu), + NWeekday::Every(Weekday::Fri), + ]), + Some(SpecialDays::Weekend) => rule.by_weekday(vec![ + NWeekday::Every(Weekday::Thu), + NWeekday::Every(Weekday::Fri), + ]), + }; + Ok(rule) +} diff --git a/src/task/mod.rs b/src/task/mod.rs index f4f1d9fd6..49115861d 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -1,6 +1,7 @@ #![allow(clippy::module_inception)] mod annotation; mod data; +mod iter; mod status; mod tag; mod task; @@ -8,6 +9,7 @@ mod time; pub use annotation::Annotation; pub use data::TaskData; +pub use iter::{str2rrule, IterType}; pub use status::Status; pub use tag::Tag; pub use task::Task; diff --git a/src/task/task.rs b/src/task/task.rs index 6cad0837c..78c8d9fda 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -3,10 +3,11 @@ use super::{utc_timestamp, Annotation, Status, Tag, Timestamp}; use crate::depmap::DependencyMap; use crate::errors::{Error, Result}; use crate::storage::TaskMap; +use crate::task::{iter, IterType}; use crate::{Operations, TaskData}; use chrono::prelude::*; use log::trace; -use rrule::Tz; +use rrule::{RRuleSet, Tz}; use std::convert::AsRef; use std::convert::TryInto; use std::str::FromStr; @@ -312,7 +313,17 @@ impl Task { self.set_timestamp(Prop::End.as_ref(), None, ops)?; } } - Status::Completed | Status::Deleted => { + Status::Completed => { + if self.get_status() == Status::Iterative { + // Create clone with Completed status. + // Generate new due date. + } + // set "end" when a task is deleted or completed + if !self.data.has(Prop::End.as_ref()) { + self.set_timestamp(Prop::End.as_ref(), Some(Utc::now()), ops)?; + } + } + Status::Deleted => { // set "end" when a task is deleted or completed if !self.data.has(Prop::End.as_ref()) { self.set_timestamp(Prop::End.as_ref(), Some(Utc::now()), ops)?; @@ -325,19 +336,21 @@ impl Task { // For the proof of concept, we'll assume that the rrule dt_start // time is now. In the future this could be parsed from 'iter'. let dt_start = chrono::Utc::now().with_timezone(&Tz::Local(chrono::Local)); - // Create the rrule. For the proof of concept, we'll assume - // that 'iter' is an rrule. In the future, we'd parse 'iter' - // into one. - let rule: rrule::RRule = iter.parse().map_err(|e| { - Error::Usage(format!("Couldn't parse iter into rrule: {e}")) - })?; + // Create the rrule. + let rule = iter::str2rrule(iter)?; let rule = rule .validate(dt_start) - .map_err(|e| Error::Usage(format!("Couldn't validate rrule: {e}")))? - .to_string(); + .map_err(|e| Error::Usage(format!("Couldn't validate rrule: {e}")))?; + let rrule_set = RRuleSet::new(dt_start).rrule(rule); // Set the rrule value. - self.set_value("rrule", Some(rule), ops)?; + self.set_value("rrule", Some(rrule_set.to_string()), ops)?; + // Check that iter_type exists, otherwise assume chained. + if self.data.get("iter_type").is_none() { + self.set_value("iter_type", Some(IterType::Chained.to_string()), ops)?; + } // Set the next due date. + let due = rrule_set.after(dt_start).all(1).dates[0].to_utc(); + self.set_due(Some(due), ops)?; } else { return Err(Error::Usage( "Iterative tasks require an 'iter' value.".into(), From ed3cdb7172308c835203e70c3e963716fc4b299e Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Mon, 4 May 2026 20:57:56 -0700 Subject: [PATCH 03/10] Add tests and fixed weekend bug. --- src/task/iter.rs | 160 ++++++++++++++++++++++++++++++++++++++++++++++- src/task/task.rs | 53 ++++++++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/src/task/iter.rs b/src/task/iter.rs index 5a85e7f1b..ff08bf70e 100644 --- a/src/task/iter.rs +++ b/src/task/iter.rs @@ -1,6 +1,7 @@ use crate::errors::{Error, Result}; use rrule::{Frequency, NWeekday, RRule, Unvalidated, Weekday}; use strum_macros::{Display, EnumString}; + /// The iteration type of a task. #[derive(Debug, PartialEq, Eq, Clone, Display, EnumString)] #[repr(C)] @@ -91,9 +92,164 @@ pub fn str2rrule(value: &str) -> Result> { NWeekday::Every(Weekday::Fri), ]), Some(SpecialDays::Weekend) => rule.by_weekday(vec![ - NWeekday::Every(Weekday::Thu), - NWeekday::Every(Weekday::Fri), + NWeekday::Every(Weekday::Sat), + NWeekday::Every(Weekday::Sun), ]), }; Ok(rule) } + +#[cfg(test)] +mod test { + use super::*; + use rrule::{Tz, Validated}; + use std::str::FromStr; + + /// Validate an rrule string input and return its validated form. + fn validate_rrule(input: &str) -> RRule { + let dt_start = chrono::Utc::now().with_timezone(&Tz::Local(chrono::Local)); + let rule = str2rrule(input).unwrap(); + let validated = rule.validate(dt_start).unwrap(); + validated + } + + #[test] + fn basic_daily() { + let rule = validate_rrule("daily"); + assert_eq!(rule.get_freq(), Frequency::Daily); + assert_eq!(rule.get_interval(), 1); + } + + #[test] + fn basic_weekly() { + let rule = validate_rrule("weekly"); + assert_eq!(rule.get_freq(), Frequency::Weekly); + assert_eq!(rule.get_interval(), 1); + } + + #[test] + fn basic_monthly() { + let rule = validate_rrule("month"); + assert_eq!(rule.get_freq(), Frequency::Monthly); + assert_eq!(rule.get_interval(), 1); + } + + #[test] + fn basic_yearly() { + let rule = validate_rrule("year"); + assert_eq!(rule.get_freq(), Frequency::Yearly); + assert_eq!(rule.get_interval(), 1); + } + + #[test] + fn interval_prefix() { + let rule = validate_rrule("3wk"); + assert_eq!(rule.get_freq(), Frequency::Weekly); + assert_eq!(rule.get_interval(), 3); + } + + #[test] + fn special_fortnight() { + let rule = validate_rrule("fortnight"); + assert_eq!(rule.get_freq(), Frequency::Weekly); + assert_eq!(rule.get_interval(), 2); + } + + #[test] + fn special_biweekly() { + let rule = validate_rrule("biweekly"); + assert_eq!(rule.get_freq(), Frequency::Weekly); + assert_eq!(rule.get_interval(), 2); + } + + #[test] + fn special_semiannual() { + let rule = validate_rrule("semiannual"); + assert_eq!(rule.get_freq(), Frequency::Monthly); + assert_eq!(rule.get_interval(), 6); + } + + #[test] + fn special_biannual() { + let rule = validate_rrule("biannual"); + assert_eq!(rule.get_freq(), Frequency::Yearly); + assert_eq!(rule.get_interval(), 2); + } + + #[test] + fn quarter_single() { + let rule = validate_rrule("qtr"); + assert_eq!(rule.get_freq(), Frequency::Monthly); + assert_eq!(rule.get_interval(), 3); + } + + #[test] + fn quarter_interval() { + let rule = validate_rrule("2qtrs"); + assert_eq!(rule.get_freq(), Frequency::Monthly); + assert_eq!(rule.get_interval(), 6); + } + + #[test] + fn weekday() { + let rule = validate_rrule("weekdays"); + assert_eq!(rule.get_freq(), Frequency::Daily); + assert_eq!(rule.get_interval(), 1); + let days = rule.get_by_weekday(); + assert!(days.contains(&NWeekday::Every(Weekday::Mon))); + assert!(days.contains(&NWeekday::Every(Weekday::Tue))); + assert!(days.contains(&NWeekday::Every(Weekday::Wed))); + assert!(days.contains(&NWeekday::Every(Weekday::Thu))); + assert!(days.contains(&NWeekday::Every(Weekday::Fri))); + } + + #[test] + fn weekend() { + let rule = validate_rrule("weekdays"); + assert_eq!(rule.get_freq(), Frequency::Daily); + assert_eq!(rule.get_interval(), 1); + let days = rule.get_by_weekday(); + assert!(days.contains(&NWeekday::Every(Weekday::Sun))); + assert!(days.contains(&NWeekday::Every(Weekday::Mon))); + } + + #[test] + fn case_insensitive() { + let rule = validate_rrule("DAily"); + assert_eq!(rule.get_freq(), Frequency::Daily); + assert_eq!(rule.get_interval(), 1); + let rule = validate_rrule("3wK"); + assert_eq!(rule.get_freq(), Frequency::Weekly); + assert_eq!(rule.get_interval(), 3); + } + + #[test] + fn trim_whitespace() { + let rule = validate_rrule(" daily "); + assert_eq!(rule.get_freq(), Frequency::Daily); + assert_eq!(rule.get_interval(), 1); + } + + #[test] + fn invalid_period() { + let result = str2rrule("3blarg"); + assert!(matches!(result, Err(Error::Usage(_)))); + } + + #[test] + fn empty_period() { + let result = str2rrule(""); + assert!(matches!(result, Err(Error::Usage(_)))); + } + + #[test] + fn iter_type_from_str() { + assert_eq!(IterType::from_str("fixed").unwrap(), IterType::Fixed); + assert_eq!(IterType::from_str("fx").unwrap(), IterType::Fixed); + assert_eq!(IterType::from_str("fixed+").unwrap(), IterType::FixedPlus); + assert_eq!(IterType::from_str("f+").unwrap(), IterType::FixedPlus); + assert_eq!(IterType::from_str("fp").unwrap(), IterType::FixedPlus); + assert_eq!(IterType::from_str("chained").unwrap(), IterType::Chained); + assert_eq!(IterType::from_str("ch").unwrap(), IterType::Chained); + } +} diff --git a/src/task/task.rs b/src/task/task.rs index 78c8d9fda..2c79476ee 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -1129,6 +1129,59 @@ mod test { .await; } + #[tokio::test] + async fn test_set_status_iterative_basic() { + with_mut_task( + |task, ops| { + task.data.update("iter", Some("daily".into()), ops); + task.set_status(Status::Iterative, ops).unwrap(); + }, + |task| { + assert_eq!(task.get_status(), Status::Iterative); + assert!(task.data.has("rrule")); + assert!(task.get_due().is_some()); + assert_eq!(task.data.get("iter_type"), Some("chained")); + }, + ) + .await; + } + + #[tokio::test] + async fn test_set_status_iterative_preserves_iter_type() { + with_mut_task( + |task, ops| { + task.data.update("iter", Some("weekly".into()), ops); + task.data.update("iter_type", Some("fixed".into()), ops); + task.set_status(Status::Iterative, ops).unwrap(); + }, + |task| { + assert_eq!(task.data.get("iter_type"), Some("fixed")); + }, + ) + .await; + } + + #[tokio::test] + async fn test_set_status_iterative_no_iter() { + let mut replica = Replica::new(InMemoryStorage::new()); + let mut ops = Operations::new(); + let uuid = Uuid::new_v4(); + let mut task = replica.create_task(uuid, &mut ops).await.unwrap(); + let result = task.set_status(Status::Iterative, &mut ops); + assert!(matches!(result, Err(Error::Usage(_)))); + } + + #[tokio::test] + async fn test_set_status_iterative_invalid_iter() { + let mut replica = Replica::new(InMemoryStorage::new()); + let mut ops = Operations::new(); + let uuid = Uuid::new_v4(); + let mut task = replica.create_task(uuid, &mut ops).await.unwrap(); + task.data.update("iter", Some("3blarg".into()), &mut ops); + let result = task.set_status(Status::Iterative, &mut ops); + assert!(matches!(result, Err(Error::Usage(_)))); + } + #[tokio::test] async fn test_set_get_value() { let property = "property-name"; From 3c9ed79c19c1cd86cc5187de0a02fe6268f2a1ce Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Tue, 5 May 2026 10:27:53 -0700 Subject: [PATCH 04/10] Add completed iterative task handling. --- src/errors.rs | 3 + src/task/iter.rs | 27 +++--- src/task/task.rs | 232 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 245 insertions(+), 17 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index 79e3c2b1c..ed7aa6417 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -19,6 +19,9 @@ pub enum Error { /// A usage error #[error("Usage Error: {0}")] Usage(String), + /// An iterative task related error + #[error("Iteration Error: {0}")] + Iterative(String), /// A general error. #[error(transparent)] Other(#[from] anyhow::Error), diff --git a/src/task/iter.rs b/src/task/iter.rs index ff08bf70e..f10b112a1 100644 --- a/src/task/iter.rs +++ b/src/task/iter.rs @@ -3,20 +3,16 @@ use rrule::{Frequency, NWeekday, RRule, Unvalidated, Weekday}; use strum_macros::{Display, EnumString}; /// The iteration type of a task. -#[derive(Debug, PartialEq, Eq, Clone, Display, EnumString)] +#[derive(Default, Debug, PartialEq, Eq, Clone, Display, EnumString)] #[repr(C)] pub enum IterType { #[strum(serialize = "fixed", serialize = "fx")] Fixed, #[strum(serialize = "fixed+", serialize = "f+", serialize = "fp")] FixedPlus, + #[default] #[strum(serialize = "chained", serialize = "ch")] Chained, - /// Unknown signifies an iter type in the task DB that was not - /// recognized. This supports forward-compatibility if a - /// new type is added. Tasks with unknown iter types should - /// be ignored (but not deleted). - Unknown(String), } enum SpecialDays { @@ -59,7 +55,7 @@ pub fn str2rrule(value: &str) -> Result> { let mut special_days: Option = None; let freq = match period { "se" | "sec" | "second" | "seconds" | "secondly" => Frequency::Secondly, - "mi" | "min" | "minute" | "minutes" | "minutley" => Frequency::Minutely, + "mi" | "min" | "minute" | "minutes" | "minutely" => Frequency::Minutely, "hr" | "hour" | "hours" | "hourly" => Frequency::Hourly, "day" | "days" | "daily" => Frequency::Daily, "wk" | "week" | "weekly" | "wkly" => Frequency::Weekly, @@ -72,7 +68,7 @@ pub fn str2rrule(value: &str) -> Result> { Frequency::Daily } "mo" | "month" | "months" | "monthly" => Frequency::Monthly, - "qtr" | "qtrs" | "quarter" | "quarterly" => { + "qtr" | "qtrs" | "quarter" | "quarters" | "quarterly" => { interval *= 3; Frequency::Monthly } @@ -185,9 +181,9 @@ mod test { #[test] fn quarter_interval() { - let rule = validate_rrule("2qtrs"); + let rule = validate_rrule("3qtrs"); assert_eq!(rule.get_freq(), Frequency::Monthly); - assert_eq!(rule.get_interval(), 6); + assert_eq!(rule.get_interval(), 9); } #[test] @@ -201,16 +197,23 @@ mod test { assert!(days.contains(&NWeekday::Every(Weekday::Wed))); assert!(days.contains(&NWeekday::Every(Weekday::Thu))); assert!(days.contains(&NWeekday::Every(Weekday::Fri))); + assert!(!days.contains(&NWeekday::Every(Weekday::Sat))); + assert!(!days.contains(&NWeekday::Every(Weekday::Sun))); } #[test] fn weekend() { - let rule = validate_rrule("weekdays"); + let rule = validate_rrule("weekend"); assert_eq!(rule.get_freq(), Frequency::Daily); assert_eq!(rule.get_interval(), 1); let days = rule.get_by_weekday(); + assert!(!days.contains(&NWeekday::Every(Weekday::Mon))); + assert!(!days.contains(&NWeekday::Every(Weekday::Tue))); + assert!(!days.contains(&NWeekday::Every(Weekday::Wed))); + assert!(!days.contains(&NWeekday::Every(Weekday::Thu))); + assert!(!days.contains(&NWeekday::Every(Weekday::Fri))); + assert!(days.contains(&NWeekday::Every(Weekday::Sat))); assert!(days.contains(&NWeekday::Every(Weekday::Sun))); - assert!(days.contains(&NWeekday::Every(Weekday::Mon))); } #[test] diff --git a/src/task/task.rs b/src/task/task.rs index 2c79476ee..32f429187 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -14,6 +14,34 @@ use std::str::FromStr; use std::sync::Arc; use uuid::Uuid; +#[cfg(not(test))] +fn now() -> DateTime { + Utc::now() +} + +#[cfg(test)] +mod mock_time { + use chrono::{DateTime, Utc}; + use std::cell::Cell; + thread_local! { + static T: Cell> = Cell::new(None); + } + pub(super) fn now() -> DateTime { + T.with(|t| match t.get() { + Some(secs) => DateTime::from_timestamp(secs, 0).unwrap(), + None => Utc::now(), + }) + } + pub(super) fn set(t: DateTime) { + T.with(|c| c.set(Some(t.timestamp()))); + } + pub(super) fn reset() { + T.with(|c| c.set(None)); + } +} +#[cfg(test)] +use mock_time::now; + /// A task, with a high-level interface. /// /// Building on [`crate::TaskData`], this type implements the task model, with ergonomic APIs to @@ -316,26 +344,88 @@ impl Task { Status::Completed => { if self.get_status() == Status::Iterative { // Create clone with Completed status. + let uuid = Uuid::new_v4(); + let mut dup = Task::new(TaskData::create(uuid, ops), self.depmap.clone()); + for (prop, value) in self.data.iter() { + dup.data.update(prop, Some(value.to_owned()), ops); + } + dup.set_value( + Prop::Status.as_ref(), + Some(String::from(Status::Completed.to_taskmap())), + ops, + )?; + dup.set_timestamp(Prop::End.as_ref(), Some(now()), ops)?; // Generate new due date. + let iter_type = match self.data.get("iter_type") { + Some(t) => IterType::from_str(t).unwrap_or_default(), + None => IterType::Chained, + }; + let rule_str = self.data.get("rrule").ok_or_else(|| { + Error::Iterative("Couldn't get rrule from iter task.".into()) + })?; + let orig_due = self + .get_due() + .unwrap_or_else(now) + .with_timezone(&Tz::Local(chrono::Local)); + + let due = match iter_type { + IterType::Fixed => { + // create rule set from rule and orig date + let rrule_set = RRuleSet::from_str(rule_str).map_err(|e| { + Error::Iterative(format!("Couldn't get rule set from rule: {}", e)) + })?; + // get first date strictly after orig due date + let after = orig_due + chrono::Duration::seconds(1); + rrule_set.after(after).all(1).dates[0].to_utc() + } + IterType::FixedPlus => { + // create rule set from rule and orig date + let rrule_set = RRuleSet::from_str(rule_str).map_err(|e| { + Error::Iterative(format!("Couldn't get rule set from rule: {}", e)) + })?; + // get first date strictly after now + let now = now().with_timezone(&Tz::Local(chrono::Local)); + let after = now + chrono::Duration::seconds(1); + rrule_set.after(after).all(1).dates[0].to_utc() + } + IterType::Chained => { + // get now + let now = now().with_timezone(&Tz::Local(chrono::Local)); + // rebuild the rule anchored to now using the stored iter string + let iter_str = self.data.get("iter").ok_or_else(|| { + Error::Iterative("Couldn't get iter from iter task.".into()) + })?; + let rule = iter::str2rrule(iter_str)?.validate(now).map_err(|e| { + Error::Usage(format!("Couldn't validate rrule: {e}")) + })?; + // get first date after now + let rrule_set = RRuleSet::new(now).rrule(rule); + rrule_set + .after(now + chrono::Duration::seconds(1)) + .all(1) + .dates[0] + .to_utc() + } + }; + self.set_due(Some(due), ops)?; } // set "end" when a task is deleted or completed if !self.data.has(Prop::End.as_ref()) { - self.set_timestamp(Prop::End.as_ref(), Some(Utc::now()), ops)?; + self.set_timestamp(Prop::End.as_ref(), Some(now()), ops)?; } } Status::Deleted => { // set "end" when a task is deleted or completed if !self.data.has(Prop::End.as_ref()) { - self.set_timestamp(Prop::End.as_ref(), Some(Utc::now()), ops)?; + self.set_timestamp(Prop::End.as_ref(), Some(now()), ops)?; } } Status::Iterative => { // Check that there is a an 'iter' value. if let Some(iter) = self.data.get("iter") { - // calculate dates, etc // For the proof of concept, we'll assume that the rrule dt_start - // time is now. In the future this could be parsed from 'iter'. - let dt_start = chrono::Utc::now().with_timezone(&Tz::Local(chrono::Local)); + // time is now. In the future this could be parsed from 'iter', . + let dt_start = now().with_timezone(&Tz::Local(chrono::Local)); // Create the rrule. let rule = iter::str2rrule(iter)?; let rule = rule @@ -1182,6 +1272,138 @@ mod test { assert!(matches!(result, Err(Error::Usage(_)))); } + async fn setup_iterative_task( + iter: &str, + iter_type: Option<&str>, + ) -> (Replica, Task, Operations, Uuid) { + let mut replica = Replica::new(InMemoryStorage::new()); + let mut ops = Operations::new(); + let uuid = Uuid::new_v4(); + let mut task = replica.create_task(uuid, &mut ops).await.unwrap(); + task.data.update("iter", Some(iter.into()), &mut ops); + if let Some(t) = iter_type { + task.data.update("iter_type", Some(t.into()), &mut ops); + } + task.set_status(Status::Iterative, &mut ops).unwrap(); + (replica, task, ops, uuid) + } + + #[tokio::test] + async fn test_complete_iterative_chained() { + let (_, mut task, mut ops, _) = setup_iterative_task("daily", None).await; + task.set_status(Status::Completed, &mut ops).unwrap(); + let due_after = task.get_due().unwrap(); + // Chained anchors to now, so the next occurrence is now + 1 day (in the future). + assert!( + due_after > Utc::now(), + "due should be in the future: {due_after}" + ); + } + + #[tokio::test] + async fn test_complete_iterative_fixed() { + let (_, mut task, mut ops, _) = setup_iterative_task("daily", Some("fixed")).await; + let due_before = task.get_due().unwrap(); + task.set_status(Status::Completed, &mut ops).unwrap(); + let due_after = task.get_due().unwrap(); + // Fixed advances from the original schedule, so the new due is strictly after the old. + assert!( + due_after > due_before, + "due should advance: before={due_before}, after={due_after}" + ); + } + + #[tokio::test] + async fn test_complete_iterative_fixed_plus() { + let (_, mut task, mut ops, _) = setup_iterative_task("daily", Some("fixed+")).await; + task.set_status(Status::Completed, &mut ops).unwrap(); + let due_after = task.get_due().unwrap(); + // FixedPlus anchors to now, so the next occurrence is now + 1 day (in the future). + assert!( + due_after > Utc::now(), + "due should be in the future: {due_after}" + ); + } + + #[tokio::test] + async fn test_complete_iterative_creates_clone() { + let (mut replica, mut task, mut ops, uuid) = setup_iterative_task("daily", None).await; + task.set_status(Status::Completed, &mut ops).unwrap(); + replica.commit_operations(ops).await.unwrap(); + + let all = replica.all_tasks().await.unwrap(); + assert_eq!(all.len(), 2, "should have original + completed clone"); + + let clone = all.values().find(|t| t.get_uuid() != uuid).unwrap(); + assert_eq!(clone.get_status(), Status::Completed); + assert!(clone.data.has("end")); + } + + #[tokio::test] + async fn test_complete_iterative_no_rrule_error() { + let mut replica = Replica::new(InMemoryStorage::new()); + let mut ops = Operations::new(); + let uuid = Uuid::new_v4(); + let mut task = replica.create_task(uuid, &mut ops).await.unwrap(); + // Manually set status=iterative without going through set_status(Iterative), + // so no "rrule" property is stored. + task.data + .update("status", Some("iterative".into()), &mut ops); + let result = task.set_status(Status::Completed, &mut ops); + assert!(matches!(result, Err(Error::Iterative(_)))); + } + + // time_start = 2026-01-01 00:00:00 UTC. Weekly occurrences: Jan 8, Jan 15, Jan 22, Jan 29 … + // time_twenty_four_days_later = 2026-01-25 00:00:00 UTC (task is ~2.5 weeks overdue when completed). + fn time_start() -> DateTime { + Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap() + } + fn time_twenty_four_days_later() -> DateTime { + Utc.with_ymd_and_hms(2026, 1, 25, 0, 0, 0).unwrap() + } + + #[tokio::test] + async fn test_fixed_advances_from_schedule() { + super::mock_time::set(time_start()); + let (_, mut task, mut ops, _) = setup_iterative_task("weekly", Some("fixed")).await; + super::mock_time::set(time_twenty_four_days_later()); + task.set_status(Status::Completed, &mut ops).unwrap(); + super::mock_time::reset(); + // Fixed: first weekly occurrence strictly after orig_due (Jan 8) = Jan 15 + assert_eq!( + task.get_due(), + Some(time_start() + chrono::Duration::weeks(1)) + ); + } + + #[tokio::test] + async fn test_fixed_plus_advances_from_now() { + super::mock_time::set(time_start()); + let (_, mut task, mut ops, _) = setup_iterative_task("weekly", Some("fixed+")).await; + super::mock_time::set(time_twenty_four_days_later()); + task.set_status(Status::Completed, &mut ops).unwrap(); + super::mock_time::reset(); + // FixedPlus: first weekly occurrence strictly after now (Jan 25) = Jan 29 + assert_eq!( + task.get_due(), + Some(time_start() + chrono::Duration::weeks(4)) + ); + } + + #[tokio::test] + async fn test_chained_advances_period_from_now() { + super::mock_time::set(time_start()); + let (_, mut task, mut ops, _) = setup_iterative_task("weekly", None).await; + super::mock_time::set(time_twenty_four_days_later()); + task.set_status(Status::Completed, &mut ops).unwrap(); + super::mock_time::reset(); + // Chained: new rrule anchored to now (Jan 25), first occurrence after now = Feb 1 + assert_eq!( + task.get_due(), + Some(time_twenty_four_days_later() + chrono::Duration::weeks(1)) + ); + } + #[tokio::test] async fn test_set_get_value() { let property = "property-name"; From ee022cda79851ffe6fd113f3276cd04af949e014 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Tue, 5 May 2026 10:34:07 -0700 Subject: [PATCH 05/10] clippy --- src/task/iter.rs | 3 +-- src/task/task.rs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/task/iter.rs b/src/task/iter.rs index f10b112a1..c0f0f4acd 100644 --- a/src/task/iter.rs +++ b/src/task/iter.rs @@ -105,8 +105,7 @@ mod test { fn validate_rrule(input: &str) -> RRule { let dt_start = chrono::Utc::now().with_timezone(&Tz::Local(chrono::Local)); let rule = str2rrule(input).unwrap(); - let validated = rule.validate(dt_start).unwrap(); - validated + rule.validate(dt_start).unwrap() } #[test] diff --git a/src/task/task.rs b/src/task/task.rs index 32f429187..848037abd 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -24,7 +24,7 @@ mod mock_time { use chrono::{DateTime, Utc}; use std::cell::Cell; thread_local! { - static T: Cell> = Cell::new(None); + static T: Cell> = const {Cell::new(None)}; } pub(super) fn now() -> DateTime { T.with(|t| match t.get() { From 8ec11988c9a33b15a7c390a1d3d94ec73a854b33 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Tue, 5 May 2026 10:48:24 -0700 Subject: [PATCH 06/10] add spec document --- docs/src/iterative-tasks.md | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/src/iterative-tasks.md diff --git a/docs/src/iterative-tasks.md b/docs/src/iterative-tasks.md new file mode 100644 index 000000000..5298c84de --- /dev/null +++ b/docs/src/iterative-tasks.md @@ -0,0 +1,126 @@ +# TaskChampion Iterative Tasks + +There are several issues with how the TaskWarrior/TaskChampion ecosystem handles recurring tasks. See the TaskWarrior [issue list](https://github.com/GothenburgBitFactory/taskwarrior/issues?q=is%3Aissue%20state%3Aopen%20label%3Atopic%3Arecurrence) for examples. + +This proposal is for a significant redesign of the recurrence system, with a different set of trade offs. Therefore, it is currently being proposed in a way that it could either replace or live alongside the current recurring task system. + +### Nomenclature + +In order to help keep things clear and for a few technical reasons, the proposed system will be referred to as iterative tasks, while the current system will be referred to as recurring tasks. + +## Iterative Tasks Main Ideas + +The major components of the iterative task system are: + +- 1. Iteration handling in TaskChampion +- 2. New task status +- 3. Iteration Types +- 4. RRULE based iteration scheduling +- 5. Iterative task flow + +### Iteration Handling in TaskChampion + +Currently, task recurrence is handled by the various frontends: primarily TaskWarrior, but also the mobile apps, web apps, and other platforms. This means that each frontend has to implement their own recurrence handler, each with its own quirks and syncing issues. + +Task iteration will be handled in TaskChampion, as discussed in [issue 595](https://github.com/GothenburgBitFactory/taskchampion/issues/595). This will primarily be done in the Task struct with additions to the constructor and set_status functions. This does mean that frontends that primarily rely on the underlying TaskData struct will miss out on task iteration. + +The frontends will see iterative tasks as normal tasks, with due, start, wait, end, and other properties as expected. The primary difference will be the new Iterative status and a few UDAs. + +### New Task Status + +A new task status will be created, Iterative, for iterative tasks. This will prevent current recurrence code from interfering with the iteration system and vice versa. + +Note: This could be changed, see [Upgrading](#Upgrading) for more discussion. + +### Iteration Types + +There are several styles of iteration used in todo applications generally, but two stand out as most common: + +#### Fixed Interval + +When a fixed interval task is completed, the next due date is set to the next interval from when it was originally due. As an example, if rent is due on the 20th of the month, the next due date will be on the 20th of the next month, regardless of if I paid it on the 15th, the 20th or the 25th. + +This does invite the question of how to handle tasks that are overdue by more than their iteration period. If I missed January and February's payments, should completing the task once move the next due date to February 20th or March 20th? Lets call just moving to the next interval "fixed" and the next interval in the future "fixed+" + +#### Chained + +When a chained interval task is completed, the next due date is set to the next interval from when it was completed. As an example, if I normally get a haircut every six weeks and I got one today, my next one should be six weeks from today, even if I got this one three weeks late, or two days early. + +In order to handle these iteration styles, an "iter_type" UDA will be added, which can be one of "fixed", "fixed+", and "chained". + +### RRULE Based Iteration Definition + +There are a lot of ways to define iteration periods, which can be extremely complex. "Every four years on the second Tuesday in November", "The first weekday that isn't a Monday, on or after the 15th of April", and "Weekly on Monday, Tuesday, Thursday and Friday, except for the third Friday of the month" are all things that I have to deal with. + +Implementing those kinds of rules is difficult, but luckily RFC 5545 Section 3.8.5.3 exists for just this kind of thing. RRules are capable of representing many, though of course not all, iteration periods and there is a well tested RRule library for Rust. + +Combining RRules with the iteration types will take a bit of though. Fixed and fixed+ is easy enough, but in order to do chained it will be necessary to modify the DTSTART rule upon task completion. + +#### RRule Generation + +For all advantages of RRules, they are a unique syntax that no one wants to write regularly. There are plain English (or other languages?) to RRule parsers for several programming languages, though not Rust. Looking at the current supported recurrence frequencies, they all map to RRules in a simple mechanical way. We could start with a simple parser that covers the current set, then expand it in the future. + +This would happen in the following phases: + +1. Map current recur values to Rrules. This is a straightforward mechanical mapping. +2. Simple descriptions: every Monday, Every six weeks, alternate Thursdays, etc +3. Compound descriptions: Every Monday, Wednesday, and Sunday. The last Friday of every month, etc +4. (maybe) Fuzzy descriptions and calculated holidays + +### Iterative Task Flow + +An iterative task consists of a single task object, like any other task. In general, an Iterative task will be handled the same as any other task, except at two points during its lifetime. To put too fine a point on it, Iterative tasks do not need any special handling like recurring tasks do, outside of two places that TaskChampion deals with. + +#### Creation / Status Change + +When an Iterative take is created, it must have Iterative as its status and an ‘iter’ entry, similar to a recurring task. When Task::new is invoked, the 'iter', 'start', 'end', and 'until' will be parsed into an RRule and set as a 'rrule' UDA. The first due date will be calculated, if not already set, along with any other properties needed. At this point, an iterative task acts the same as any other task + +If a task is changed from some other status to iterative using the Task::change_status function, the same logic as creation is followed. + +#### Done + +The second time that an Iterative task has special handling is when it has its status set to “Completed” using Task::set_status or Task::done. + +First, a copy of the task will be created, with a new UUID and a “Completed” status. It will also have a link back to the original iterative task via the 'parent' UUID attribute. This is similar to the TaskWarrior ’log’ command. + +Second, the next due date and wait are calculated from the RRule. For fixed or fixed+ styles, this is a single RRule library call. For chained, it requires updating the RRULEs DTSTART before calling. + +Yes, this is basically an inversion of the current recurring system, where a hidden “parent” task spawns new pending tasks. The advantage is that since the spawned tasks are completed, it’s not possible to end up with multiple pending tasks that are all copies of each other. In case of a sync issue, there may be multiple copies of completed tasks, but that is far less important. Iterative tasks also require less special handling, as there is no need to hide a parent task most of the time and then still have a way to find it when the user wants to edit or delete it. + +### Integration With TaskWarrior + +Integration with TaskChampion primarily involves adding the new Iterative status, handling hooks in task creation and status change, and the RRule generator. + +Integration with TaskWarrior will require adding the new Iterative status so that Iterative tasks are visible, and ‘iter’ as a built in UDA. + +## Potential Issues and Limitations + +There are a few potential issues with this approach + +### Legacy Applications + +First, pre-3.0 based applications are completely left out as all iterative functionality is implemented in TaskChampion. + +Second, all applications that haven’t been updated to recognize the Iterative status may hide or mishandle Iterative tasks. The biggest issue is likely to be marking an Iterative task done and then losing it as an iterative task. If that happens, it will disappear from that client's perspective and the iteration never advances. There are a few possible mitigations, such as a secondary UDA flag that legacy clients wouldn't set, but this might just need to be documented as a possible issue during a transition phase. This is the largest risk in the proposal. + +### RRule Modification + +Modifying the RRule on task completion for chained tasks does make the RRule history inconsistent. Searching for things like “give me the last five dates this was done” will need to be done by searching the spawned completed tasks rather than just back calculating the RRule. This is almost certainly a minor issue for almost all use cases. + +### Multiple Upcoming Tasks + +The recurring task system supports having multiple open upcoming tasks based on the recurring template task. Iterative tasks don’t support that, but calculating the next arbitrary number of due dates is trivial, which should cover most use cases. + +## Alternatives + +### Upgrading + +One possible alternative to maintaining two separate systems for periodic tasks would be to upgrade existing recurring tasks to the new system. + +The first approach would be to have TaskChampion just start treating recurring tasks as iterative tasks. This would need to be a major breaking version, as using task recurrence with legacy tools would cause many problems. + +The second would be to have TaskChampion migrate existing recurring tasks to iterative tasks. This could be done, but should only be an option if specificity requested. + +### Just Moving Recurrence Handling + +It would be possible to move the current recurrence system to TaskChampion, either as is of with the changes suggested in the [abandoned RFC](https://github.com/GothenburgBitFactory/taskwarrior/blob/develop/doc/devel/rfcs/recurrence.md). This would be simpler in several ways, but would still have most of the same backwards compatibility issues and not fix many of the known problems with recurring tasks. From 7cd58d8df0385bff29b752cb4810ab88c9b6a9f0 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Tue, 5 May 2026 11:06:55 -0700 Subject: [PATCH 07/10] Fix botched merge --- Cargo.lock | 10 ++++++++++ src/task/task.rs | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d116a105..4d88f2598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,6 +571,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "cmov" version = "0.5.3" diff --git a/src/task/task.rs b/src/task/task.rs index bffaf4e19..848037abd 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -335,9 +335,9 @@ impl Task { /// This also updates the task's "end" property appropriately. pub fn set_status(&mut self, status: Status, ops: &mut Operations) -> Result<()> { match status { - Status::Pending | Status::Recurring + Status::Pending | Status::Recurring => { // clear "end" when a task becomes "pending" or "recurring" - if self.data.has(Prop::End.as_ref()) => { + if self.data.has(Prop::End.as_ref()) { self.set_timestamp(Prop::End.as_ref(), None, ops)?; } } From a8b70ceb14433763d105a9040474342cbd1ddb22 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Wed, 6 May 2026 13:15:37 -0700 Subject: [PATCH 08/10] add iterative parent task uuid to spawned tasks --- src/lib.rs | 2 +- src/task/iter.rs | 7 ++-- src/task/mod.rs | 4 +- src/task/task.rs | 98 +++++++++++++++++++++++------------------------- src/task/time.rs | 27 +++++++++++++ 5 files changed, 81 insertions(+), 57 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7df1f5c5c..dee8da8fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ pub use server::{Server, ServerConfig}; pub use storage::indexeddb::IndexedDbStorage; #[cfg(feature = "storage-sqlite")] pub use storage::sqlite::SqliteStorage; -pub use task::{str2rrule, utc_timestamp, Annotation, IterType, Status, Tag, Task, TaskData}; +pub use task::{utc_timestamp, Annotation, IterType, Status, Tag, Task, TaskData}; pub use workingset::WorkingSet; /// Re-exported type from the `uuid` crate, for ease of compatibility for consumers of this crate. diff --git a/src/task/iter.rs b/src/task/iter.rs index c0f0f4acd..b308e7636 100644 --- a/src/task/iter.rs +++ b/src/task/iter.rs @@ -1,5 +1,6 @@ use crate::errors::{Error, Result}; -use rrule::{Frequency, NWeekday, RRule, Unvalidated, Weekday}; +pub(crate) use rrule::RRule; +use rrule::{Frequency, NWeekday, Unvalidated, Weekday}; use strum_macros::{Display, EnumString}; /// The iteration type of a task. @@ -22,7 +23,7 @@ enum SpecialDays { /// Converts an iteration description string to a RRule. /// /// For now, only handles the standard TaskWarrior style descriptions. -pub fn str2rrule(value: &str) -> Result> { +pub(crate) fn str2rrule(value: &str) -> Result> { // Most TW iteration strings are of the form: // nPP where n is the interval number and PP is the period. // e.g. 3wks -> every three weeks. @@ -69,7 +70,7 @@ pub fn str2rrule(value: &str) -> Result> { } "mo" | "month" | "months" | "monthly" => Frequency::Monthly, "qtr" | "qtrs" | "quarter" | "quarters" | "quarterly" => { - interval *= 3; + interval = interval.saturating_mul(3); Frequency::Monthly } "yr" | "year" | "yearly" | "annual" => Frequency::Yearly, diff --git a/src/task/mod.rs b/src/task/mod.rs index 49115861d..bf57e9904 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -9,9 +9,9 @@ mod time; pub use annotation::Annotation; pub use data::TaskData; -pub use iter::{str2rrule, IterType}; +pub use iter::IterType; pub use status::Status; pub use tag::Tag; pub use task::Task; pub use time::utc_timestamp; -pub(crate) use time::Timestamp; +pub(crate) use time::{utc_now, Timestamp}; diff --git a/src/task/task.rs b/src/task/task.rs index 848037abd..9c32aa65b 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -3,7 +3,7 @@ use super::{utc_timestamp, Annotation, Status, Tag, Timestamp}; use crate::depmap::DependencyMap; use crate::errors::{Error, Result}; use crate::storage::TaskMap; -use crate::task::{iter, IterType}; +use crate::task::{iter, utc_now, IterType}; use crate::{Operations, TaskData}; use chrono::prelude::*; use log::trace; @@ -14,34 +14,6 @@ use std::str::FromStr; use std::sync::Arc; use uuid::Uuid; -#[cfg(not(test))] -fn now() -> DateTime { - Utc::now() -} - -#[cfg(test)] -mod mock_time { - use chrono::{DateTime, Utc}; - use std::cell::Cell; - thread_local! { - static T: Cell> = const {Cell::new(None)}; - } - pub(super) fn now() -> DateTime { - T.with(|t| match t.get() { - Some(secs) => DateTime::from_timestamp(secs, 0).unwrap(), - None => Utc::now(), - }) - } - pub(super) fn set(t: DateTime) { - T.with(|c| c.set(Some(t.timestamp()))); - } - pub(super) fn reset() { - T.with(|c| c.set(None)); - } -} -#[cfg(test)] -use mock_time::now; - /// A task, with a high-level interface. /// /// Building on [`crate::TaskData`], this type implements the task model, with ergonomic APIs to @@ -354,18 +326,21 @@ impl Task { Some(String::from(Status::Completed.to_taskmap())), ops, )?; - dup.set_timestamp(Prop::End.as_ref(), Some(now()), ops)?; + dup.set_timestamp(Prop::End.as_ref(), Some(utc_now()), ops)?; // Generate new due date. let iter_type = match self.data.get("iter_type") { - Some(t) => IterType::from_str(t).unwrap_or_default(), + Some(t) => IterType::from_str(t).map_err(|e| { + Error::Iterative(format!("Couldn't parse iter type {}", e)) + })?, None => IterType::Chained, }; + dup.set_value("parent", Some(self.get_uuid().into()), ops)?; let rule_str = self.data.get("rrule").ok_or_else(|| { Error::Iterative("Couldn't get rrule from iter task.".into()) })?; let orig_due = self .get_due() - .unwrap_or_else(now) + .unwrap_or_else(utc_now) .with_timezone(&Tz::Local(chrono::Local)); let due = match iter_type { @@ -376,7 +351,13 @@ impl Task { })?; // get first date strictly after orig due date let after = orig_due + chrono::Duration::seconds(1); - rrule_set.after(after).all(1).dates[0].to_utc() + rrule_set + .after(after) + .all(1) + .dates + .get(0) + .ok_or_else(|| Error::Iterative("no future occurrence".into()))? + .to_utc() } IterType::FixedPlus => { // create rule set from rule and orig date @@ -384,13 +365,19 @@ impl Task { Error::Iterative(format!("Couldn't get rule set from rule: {}", e)) })?; // get first date strictly after now - let now = now().with_timezone(&Tz::Local(chrono::Local)); + let now = utc_now().with_timezone(&Tz::Local(chrono::Local)); let after = now + chrono::Duration::seconds(1); - rrule_set.after(after).all(1).dates[0].to_utc() + rrule_set + .after(after) + .all(1) + .dates + .get(0) + .ok_or_else(|| Error::Iterative("no future occurrence".into()))? + .to_utc() } IterType::Chained => { // get now - let now = now().with_timezone(&Tz::Local(chrono::Local)); + let now = utc_now().with_timezone(&Tz::Local(chrono::Local)); // rebuild the rule anchored to now using the stored iter string let iter_str = self.data.get("iter").ok_or_else(|| { Error::Iterative("Couldn't get iter from iter task.".into()) @@ -403,21 +390,24 @@ impl Task { rrule_set .after(now + chrono::Duration::seconds(1)) .all(1) - .dates[0] + .dates + .get(0) + .ok_or_else(|| Error::Iterative("no future occurrence".into()))? .to_utc() } }; self.set_due(Some(due), ops)?; + return Ok(()); } // set "end" when a task is deleted or completed if !self.data.has(Prop::End.as_ref()) { - self.set_timestamp(Prop::End.as_ref(), Some(now()), ops)?; + self.set_timestamp(Prop::End.as_ref(), Some(utc_now()), ops)?; } } Status::Deleted => { // set "end" when a task is deleted or completed if !self.data.has(Prop::End.as_ref()) { - self.set_timestamp(Prop::End.as_ref(), Some(now()), ops)?; + self.set_timestamp(Prop::End.as_ref(), Some(utc_now()), ops)?; } } Status::Iterative => { @@ -425,7 +415,7 @@ impl Task { if let Some(iter) = self.data.get("iter") { // For the proof of concept, we'll assume that the rrule dt_start // time is now. In the future this could be parsed from 'iter', . - let dt_start = now().with_timezone(&Tz::Local(chrono::Local)); + let dt_start = utc_now().with_timezone(&Tz::Local(chrono::Local)); // Create the rrule. let rule = iter::str2rrule(iter)?; let rule = rule @@ -439,7 +429,13 @@ impl Task { self.set_value("iter_type", Some(IterType::Chained.to_string()), ops)?; } // Set the next due date. - let due = rrule_set.after(dt_start).all(1).dates[0].to_utc(); + let due = rrule_set + .after(dt_start) + .all(1) + .dates + .get(0) + .ok_or_else(|| Error::Iterative("no future occurrence".into()))? + .to_utc(); self.set_due(Some(due), ops)?; } else { return Err(Error::Usage( @@ -714,7 +710,7 @@ impl Task { #[allow(deprecated)] mod test { use super::*; - use crate::{storage::inmemory::InMemoryStorage, Replica}; + use crate::{storage::inmemory::InMemoryStorage, task::time::mock_time, Replica}; use pretty_assertions::assert_eq; use std::collections::HashSet; @@ -1364,11 +1360,11 @@ mod test { #[tokio::test] async fn test_fixed_advances_from_schedule() { - super::mock_time::set(time_start()); + mock_time::set(time_start()); let (_, mut task, mut ops, _) = setup_iterative_task("weekly", Some("fixed")).await; - super::mock_time::set(time_twenty_four_days_later()); + mock_time::set(time_twenty_four_days_later()); task.set_status(Status::Completed, &mut ops).unwrap(); - super::mock_time::reset(); + mock_time::reset(); // Fixed: first weekly occurrence strictly after orig_due (Jan 8) = Jan 15 assert_eq!( task.get_due(), @@ -1378,11 +1374,11 @@ mod test { #[tokio::test] async fn test_fixed_plus_advances_from_now() { - super::mock_time::set(time_start()); + mock_time::set(time_start()); let (_, mut task, mut ops, _) = setup_iterative_task("weekly", Some("fixed+")).await; - super::mock_time::set(time_twenty_four_days_later()); + mock_time::set(time_twenty_four_days_later()); task.set_status(Status::Completed, &mut ops).unwrap(); - super::mock_time::reset(); + mock_time::reset(); // FixedPlus: first weekly occurrence strictly after now (Jan 25) = Jan 29 assert_eq!( task.get_due(), @@ -1392,11 +1388,11 @@ mod test { #[tokio::test] async fn test_chained_advances_period_from_now() { - super::mock_time::set(time_start()); + mock_time::set(time_start()); let (_, mut task, mut ops, _) = setup_iterative_task("weekly", None).await; - super::mock_time::set(time_twenty_four_days_later()); + mock_time::set(time_twenty_four_days_later()); task.set_status(Status::Completed, &mut ops).unwrap(); - super::mock_time::reset(); + mock_time::reset(); // Chained: new rrule anchored to now (Jan 25), first occurrence after now = Feb 1 assert_eq!( task.get_due(), diff --git a/src/task/time.rs b/src/task/time.rs index 9873dc798..a1b1bc583 100644 --- a/src/task/time.rs +++ b/src/task/time.rs @@ -9,3 +9,30 @@ pub fn utc_timestamp(secs: i64) -> Timestamp { _ => unreachable!("We're requesting UTC so daylight saving time isn't a factor."), } } +#[cfg(not(test))] +pub(crate) fn utc_now() -> DateTime { + Utc::now() +} + +#[cfg(test)] +pub(crate) mod mock_time { + use chrono::{DateTime, Utc}; + use std::cell::Cell; + thread_local! { + static T: Cell> = const {Cell::new(None)}; + } + pub(crate) fn utc_now() -> DateTime { + T.with(|t| match t.get() { + Some(secs) => DateTime::from_timestamp(secs, 0).unwrap(), + None => Utc::now(), + }) + } + pub(crate) fn set(t: DateTime) { + T.with(|c| c.set(Some(t.timestamp()))); + } + pub(crate) fn reset() { + T.with(|c| c.set(None)); + } +} +#[cfg(test)] +pub(crate) use mock_time::utc_now; From 0597ea2c62fb53ef92e9775570ab772f95dc0f25 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Wed, 6 May 2026 13:25:31 -0700 Subject: [PATCH 09/10] feature gate iterative tasks and deal with wasm issues --- Cargo.toml | 6 ++-- src/lib.rs | 4 ++- src/task/mod.rs | 4 +++ src/task/task.rs | 82 +++++++++++++++++++++++++++++------------------- src/task/time.rs | 12 +++++++ 5 files changed, 72 insertions(+), 36 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0e9e0ee91..6c5a10737 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ resolver = "2" crate-type = ["cdylib", "rlib"] [features] -default = ["sync", "bundled", "storage", "tls-webpki-roots"] +default = ["sync", "bundled", "storage", "tls-webpki-roots", "iterative-tasks"] # Support for all sync solutions sync = ["server-sync", "server-gcp", "server-aws", "server-local", "server-git"] @@ -50,6 +50,8 @@ server-aws = [ server-local = ["dep:rusqlite"] # Support for sync via Git server-git = ["dep:serde_with", "dep:glob", "encryption"] +# Support for iterative (recurring) tasks +iterative-tasks = ["dep:rrule"] # Support for all task storage backends (except indexeddb, which only works on WASM builds) storage = ["storage-sqlite"] @@ -96,7 +98,7 @@ tokio = { version = "1", features = ["macros", "sync", "rt"] } thiserror = "2.0" uuid = { version = "^1.23.1", features = ["serde", "v4"] } url = { version = "2", optional = true } -rrule = "0.14.0" +rrule = { version = "0.14.0", optional = true } glob = { version = "0.3.3", optional = true } ## wasm-only dependencies. diff --git a/src/lib.rs b/src/lib.rs index dee8da8fa..fd9036771 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,9 @@ pub use server::{Server, ServerConfig}; pub use storage::indexeddb::IndexedDbStorage; #[cfg(feature = "storage-sqlite")] pub use storage::sqlite::SqliteStorage; -pub use task::{utc_timestamp, Annotation, IterType, Status, Tag, Task, TaskData}; +pub use task::{utc_timestamp, Annotation, Status, Tag, Task, TaskData}; +#[cfg(feature = "iterative-tasks")] +pub use task::IterType; pub use workingset::WorkingSet; /// Re-exported type from the `uuid` crate, for ease of compatibility for consumers of this crate. diff --git a/src/task/mod.rs b/src/task/mod.rs index bf57e9904..efa6a0f89 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -1,6 +1,7 @@ #![allow(clippy::module_inception)] mod annotation; mod data; +#[cfg(feature = "iterative-tasks")] mod iter; mod status; mod tag; @@ -9,9 +10,12 @@ mod time; pub use annotation::Annotation; pub use data::TaskData; +#[cfg(feature = "iterative-tasks")] pub use iter::IterType; pub use status::Status; pub use tag::Tag; pub use task::Task; pub use time::utc_timestamp; pub(crate) use time::{utc_now, Timestamp}; +#[cfg(feature = "iterative-tasks")] +pub(crate) use time::local_tz; diff --git a/src/task/task.rs b/src/task/task.rs index 9c32aa65b..793de15c1 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -3,10 +3,13 @@ use super::{utc_timestamp, Annotation, Status, Tag, Timestamp}; use crate::depmap::DependencyMap; use crate::errors::{Error, Result}; use crate::storage::TaskMap; -use crate::task::{iter, utc_now, IterType}; +use crate::task::utc_now; +#[cfg(feature = "iterative-tasks")] +use crate::task::{iter, local_tz, IterType}; use crate::{Operations, TaskData}; use chrono::prelude::*; use log::trace; +#[cfg(feature = "iterative-tasks")] use rrule::{RRuleSet, Tz}; use std::convert::AsRef; use std::convert::TryInto; @@ -14,6 +17,7 @@ use std::str::FromStr; use std::sync::Arc; use uuid::Uuid; + /// A task, with a high-level interface. /// /// Building on [`crate::TaskData`], this type implements the task model, with ergonomic APIs to @@ -314,6 +318,7 @@ impl Task { } } Status::Completed => { + #[cfg(feature = "iterative-tasks")] if self.get_status() == Status::Iterative { // Create clone with Completed status. let uuid = Uuid::new_v4(); @@ -341,7 +346,7 @@ impl Task { let orig_due = self .get_due() .unwrap_or_else(utc_now) - .with_timezone(&Tz::Local(chrono::Local)); + .with_timezone(&local_tz()); let due = match iter_type { IterType::Fixed => { @@ -365,7 +370,7 @@ impl Task { Error::Iterative(format!("Couldn't get rule set from rule: {}", e)) })?; // get first date strictly after now - let now = utc_now().with_timezone(&Tz::Local(chrono::Local)); + let now = utc_now().with_timezone(&local_tz()); let after = now + chrono::Duration::seconds(1); rrule_set .after(after) @@ -377,7 +382,7 @@ impl Task { } IterType::Chained => { // get now - let now = utc_now().with_timezone(&Tz::Local(chrono::Local)); + let now = utc_now().with_timezone(&local_tz()); // rebuild the rule anchored to now using the stored iter string let iter_str = self.data.get("iter").ok_or_else(|| { Error::Iterative("Couldn't get iter from iter task.".into()) @@ -411,37 +416,48 @@ impl Task { } } Status::Iterative => { - // Check that there is a an 'iter' value. - if let Some(iter) = self.data.get("iter") { - // For the proof of concept, we'll assume that the rrule dt_start - // time is now. In the future this could be parsed from 'iter', . - let dt_start = utc_now().with_timezone(&Tz::Local(chrono::Local)); - // Create the rrule. - let rule = iter::str2rrule(iter)?; - let rule = rule - .validate(dt_start) - .map_err(|e| Error::Usage(format!("Couldn't validate rrule: {e}")))?; - let rrule_set = RRuleSet::new(dt_start).rrule(rule); - // Set the rrule value. - self.set_value("rrule", Some(rrule_set.to_string()), ops)?; - // Check that iter_type exists, otherwise assume chained. - if self.data.get("iter_type").is_none() { - self.set_value("iter_type", Some(IterType::Chained.to_string()), ops)?; + #[cfg(feature = "iterative-tasks")] + { + // Check that there is a an 'iter' value. + if let Some(iter) = self.data.get("iter") { + // For the proof of concept, we'll assume that the rrule dt_start + // time is now. In the future this could be parsed from 'iter', . + let dt_start = utc_now().with_timezone(&local_tz()); + // Create the rrule. + let rule = iter::str2rrule(iter)?; + let rule = rule + .validate(dt_start) + .map_err(|e| Error::Usage(format!("Couldn't validate rrule: {e}")))?; + let rrule_set = RRuleSet::new(dt_start).rrule(rule); + // Set the rrule value. + self.set_value("rrule", Some(rrule_set.to_string()), ops)?; + // Check that iter_type exists, otherwise assume chained. + if self.data.get("iter_type").is_none() { + self.set_value( + "iter_type", + Some(IterType::Chained.to_string()), + ops, + )?; + } + // Set the next due date. + let due = rrule_set + .after(dt_start) + .all(1) + .dates + .get(0) + .ok_or_else(|| Error::Iterative("no future occurrence".into()))? + .to_utc(); + self.set_due(Some(due), ops)?; + } else { + return Err(Error::Usage( + "Iterative tasks require an 'iter' value.".into(), + )); } - // Set the next due date. - let due = rrule_set - .after(dt_start) - .all(1) - .dates - .get(0) - .ok_or_else(|| Error::Iterative("no future occurrence".into()))? - .to_utc(); - self.set_due(Some(due), ops)?; - } else { - return Err(Error::Usage( - "Iterative tasks require an 'iter' value.".into(), - )); } + #[cfg(not(feature = "iterative-tasks"))] + return Err(Error::Usage( + "iterative-tasks feature is not enabled".into(), + )); } Status::Unknown(_) => {} } diff --git a/src/task/time.rs b/src/task/time.rs index a1b1bc583..178de15a9 100644 --- a/src/task/time.rs +++ b/src/task/time.rs @@ -1,4 +1,6 @@ use chrono::{offset::LocalResult, DateTime, TimeZone, Utc}; +#[cfg(feature = "iterative-tasks")] +use rrule::Tz; pub(crate) type Timestamp = DateTime; @@ -9,6 +11,16 @@ pub fn utc_timestamp(secs: i64) -> Timestamp { _ => unreachable!("We're requesting UTC so daylight saving time isn't a factor."), } } +/// Returns the local timezone for rrule scheduling. +/// On WASM, `chrono::Local` is unavailable, so UTC is used as a fallback. +#[cfg(feature = "iterative-tasks")] +pub(crate) fn local_tz() -> Tz { + #[cfg(not(target_arch = "wasm32"))] + return Tz::Local(chrono::Local); + #[cfg(target_arch = "wasm32")] + return Tz::UTC; +} + #[cfg(not(test))] pub(crate) fn utc_now() -> DateTime { Utc::now() From afe15e8961a0f88a2077c863a89b5fdd5e2d0e34 Mon Sep 17 00:00:00 2001 From: Adam Milner Date: Wed, 6 May 2026 18:19:21 -0700 Subject: [PATCH 10/10] clippy, lint, gate tests --- src/lib.rs | 2 +- src/task/mod.rs | 4 ++-- src/task/task.rs | 32 +++++++++++++++++++++----------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index fd9036771..ee4728f7f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,9 +24,9 @@ pub use server::{Server, ServerConfig}; pub use storage::indexeddb::IndexedDbStorage; #[cfg(feature = "storage-sqlite")] pub use storage::sqlite::SqliteStorage; -pub use task::{utc_timestamp, Annotation, Status, Tag, Task, TaskData}; #[cfg(feature = "iterative-tasks")] pub use task::IterType; +pub use task::{utc_timestamp, Annotation, Status, Tag, Task, TaskData}; pub use workingset::WorkingSet; /// Re-exported type from the `uuid` crate, for ease of compatibility for consumers of this crate. diff --git a/src/task/mod.rs b/src/task/mod.rs index efa6a0f89..c0a4c9611 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -15,7 +15,7 @@ pub use iter::IterType; pub use status::Status; pub use tag::Tag; pub use task::Task; -pub use time::utc_timestamp; -pub(crate) use time::{utc_now, Timestamp}; #[cfg(feature = "iterative-tasks")] pub(crate) use time::local_tz; +pub use time::utc_timestamp; +pub(crate) use time::{utc_now, Timestamp}; diff --git a/src/task/task.rs b/src/task/task.rs index 793de15c1..4627cbf55 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -10,14 +10,13 @@ use crate::{Operations, TaskData}; use chrono::prelude::*; use log::trace; #[cfg(feature = "iterative-tasks")] -use rrule::{RRuleSet, Tz}; +use rrule::RRuleSet; use std::convert::AsRef; use std::convert::TryInto; use std::str::FromStr; use std::sync::Arc; use uuid::Uuid; - /// A task, with a high-level interface. /// /// Building on [`crate::TaskData`], this type implements the task model, with ergonomic APIs to @@ -360,7 +359,7 @@ impl Task { .after(after) .all(1) .dates - .get(0) + .first() .ok_or_else(|| Error::Iterative("no future occurrence".into()))? .to_utc() } @@ -376,7 +375,7 @@ impl Task { .after(after) .all(1) .dates - .get(0) + .first() .ok_or_else(|| Error::Iterative("no future occurrence".into()))? .to_utc() } @@ -396,7 +395,7 @@ impl Task { .after(now + chrono::Duration::seconds(1)) .all(1) .dates - .get(0) + .first() .ok_or_else(|| Error::Iterative("no future occurrence".into()))? .to_utc() } @@ -433,18 +432,14 @@ impl Task { self.set_value("rrule", Some(rrule_set.to_string()), ops)?; // Check that iter_type exists, otherwise assume chained. if self.data.get("iter_type").is_none() { - self.set_value( - "iter_type", - Some(IterType::Chained.to_string()), - ops, - )?; + self.set_value("iter_type", Some(IterType::Chained.to_string()), ops)?; } // Set the next due date. let due = rrule_set .after(dt_start) .all(1) .dates - .get(0) + .first() .ok_or_else(|| Error::Iterative("no future occurrence".into()))? .to_utc(); self.set_due(Some(due), ops)?; @@ -1231,6 +1226,7 @@ mod test { .await; } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_set_status_iterative_basic() { with_mut_task( @@ -1248,6 +1244,7 @@ mod test { .await; } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_set_status_iterative_preserves_iter_type() { with_mut_task( @@ -1263,6 +1260,7 @@ mod test { .await; } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_set_status_iterative_no_iter() { let mut replica = Replica::new(InMemoryStorage::new()); @@ -1273,6 +1271,7 @@ mod test { assert!(matches!(result, Err(Error::Usage(_)))); } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_set_status_iterative_invalid_iter() { let mut replica = Replica::new(InMemoryStorage::new()); @@ -1284,6 +1283,7 @@ mod test { assert!(matches!(result, Err(Error::Usage(_)))); } + #[cfg(feature = "iterative-tasks")] async fn setup_iterative_task( iter: &str, iter_type: Option<&str>, @@ -1300,6 +1300,7 @@ mod test { (replica, task, ops, uuid) } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_complete_iterative_chained() { let (_, mut task, mut ops, _) = setup_iterative_task("daily", None).await; @@ -1312,6 +1313,7 @@ mod test { ); } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_complete_iterative_fixed() { let (_, mut task, mut ops, _) = setup_iterative_task("daily", Some("fixed")).await; @@ -1325,6 +1327,7 @@ mod test { ); } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_complete_iterative_fixed_plus() { let (_, mut task, mut ops, _) = setup_iterative_task("daily", Some("fixed+")).await; @@ -1337,6 +1340,7 @@ mod test { ); } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_complete_iterative_creates_clone() { let (mut replica, mut task, mut ops, uuid) = setup_iterative_task("daily", None).await; @@ -1351,6 +1355,7 @@ mod test { assert!(clone.data.has("end")); } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_complete_iterative_no_rrule_error() { let mut replica = Replica::new(InMemoryStorage::new()); @@ -1367,13 +1372,16 @@ mod test { // time_start = 2026-01-01 00:00:00 UTC. Weekly occurrences: Jan 8, Jan 15, Jan 22, Jan 29 … // time_twenty_four_days_later = 2026-01-25 00:00:00 UTC (task is ~2.5 weeks overdue when completed). + #[cfg(feature = "iterative-tasks")] fn time_start() -> DateTime { Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap() } + #[cfg(feature = "iterative-tasks")] fn time_twenty_four_days_later() -> DateTime { Utc.with_ymd_and_hms(2026, 1, 25, 0, 0, 0).unwrap() } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_fixed_advances_from_schedule() { mock_time::set(time_start()); @@ -1388,6 +1396,7 @@ mod test { ); } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_fixed_plus_advances_from_now() { mock_time::set(time_start()); @@ -1402,6 +1411,7 @@ mod test { ); } + #[cfg(feature = "iterative-tasks")] #[tokio::test] async fn test_chained_advances_period_from_now() { mock_time::set(time_start());