diff --git a/Cargo.lock b/Cargo.lock index 25d0e6e26..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" @@ -1832,6 +1842,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" @@ -2205,6 +2233,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" @@ -2579,6 +2620,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" @@ -2723,6 +2770,7 @@ dependencies = [ "reqwest", "reqwest-middleware", "ring", + "rrule", "rstest", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index edbc722d0..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,6 +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 = { version = "0.14.0", optional = true } glob = { version = "0.3.3", optional = true } ## wasm-only dependencies. 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. 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/lib.rs b/src/lib.rs index 1862c5172..ee4728f7f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,8 @@ pub use server::{Server, ServerConfig}; pub use storage::indexeddb::IndexedDbStorage; #[cfg(feature = "storage-sqlite")] pub use storage::sqlite::SqliteStorage; +#[cfg(feature = "iterative-tasks")] +pub use task::IterType; pub use task::{utc_timestamp, Annotation, Status, Tag, Task, TaskData}; pub use workingset::WorkingSet; diff --git a/src/task/iter.rs b/src/task/iter.rs new file mode 100644 index 000000000..b308e7636 --- /dev/null +++ b/src/task/iter.rs @@ -0,0 +1,258 @@ +use crate::errors::{Error, Result}; +pub(crate) use rrule::RRule; +use rrule::{Frequency, NWeekday, Unvalidated, Weekday}; +use strum_macros::{Display, EnumString}; + +/// The iteration type of a task. +#[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, +} + +enum SpecialDays { + Weekday, + Weekend, +} +/// Converts an iteration description string to a RRule. +/// +/// For now, only handles the standard TaskWarrior style descriptions. +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. + // 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" | "minutely" => 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" | "quarters" | "quarterly" => { + interval = interval.saturating_mul(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::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(); + rule.validate(dt_start).unwrap() + } + + #[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("3qtrs"); + assert_eq!(rule.get_freq(), Frequency::Monthly); + assert_eq!(rule.get_interval(), 9); + } + + #[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))); + assert!(!days.contains(&NWeekday::Every(Weekday::Sat))); + assert!(!days.contains(&NWeekday::Every(Weekday::Sun))); + } + + #[test] + fn weekend() { + 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))); + } + + #[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/mod.rs b/src/task/mod.rs index f4f1d9fd6..c0a4c9611 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -1,6 +1,8 @@ #![allow(clippy::module_inception)] mod annotation; mod data; +#[cfg(feature = "iterative-tasks")] +mod iter; mod status; mod tag; mod task; @@ -8,8 +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; +#[cfg(feature = "iterative-tasks")] +pub(crate) use time::local_tz; pub use time::utc_timestamp; -pub(crate) use time::Timestamp; +pub(crate) use time::{utc_now, Timestamp}; 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 57be16486..4627cbf55 100644 --- a/src/task/task.rs +++ b/src/task/task.rs @@ -3,9 +3,14 @@ use super::{utc_timestamp, Annotation, Status, Tag, Timestamp}; use crate::depmap::DependencyMap; use crate::errors::{Error, Result}; use crate::storage::TaskMap; +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; use std::convert::AsRef; use std::convert::TryInto; use std::str::FromStr; @@ -305,17 +310,151 @@ 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)?; } - Status::Completed | Status::Deleted + } + Status::Completed => { + #[cfg(feature = "iterative-tasks")] + 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(utc_now()), ops)?; + // Generate new due date. + let iter_type = match self.data.get("iter_type") { + 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(utc_now) + .with_timezone(&local_tz()); + + 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 + .first() + .ok_or_else(|| Error::Iterative("no future occurrence".into()))? + .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 = utc_now().with_timezone(&local_tz()); + let after = now + chrono::Duration::seconds(1); + rrule_set + .after(after) + .all(1) + .dates + .first() + .ok_or_else(|| Error::Iterative("no future occurrence".into()))? + .to_utc() + } + IterType::Chained => { + // get now + 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()) + })?; + 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 + .first() + .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(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)?; + if !self.data.has(Prop::End.as_ref()) { + self.set_timestamp(Prop::End.as_ref(), Some(utc_now()), ops)?; } - _ => {} + } + Status::Iterative => { + #[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 + .first() + .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(_) => {} } self.set_value( Prop::Status.as_ref(), @@ -582,7 +721,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; @@ -1087,6 +1226,206 @@ mod test { .await; } + #[cfg(feature = "iterative-tasks")] + #[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; + } + + #[cfg(feature = "iterative-tasks")] + #[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; + } + + #[cfg(feature = "iterative-tasks")] + #[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(_)))); + } + + #[cfg(feature = "iterative-tasks")] + #[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(_)))); + } + + #[cfg(feature = "iterative-tasks")] + 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) + } + + #[cfg(feature = "iterative-tasks")] + #[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}" + ); + } + + #[cfg(feature = "iterative-tasks")] + #[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}" + ); + } + + #[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; + 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}" + ); + } + + #[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; + 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")); + } + + #[cfg(feature = "iterative-tasks")] + #[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). + #[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()); + let (_, mut task, mut ops, _) = setup_iterative_task("weekly", Some("fixed")).await; + mock_time::set(time_twenty_four_days_later()); + task.set_status(Status::Completed, &mut ops).unwrap(); + 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)) + ); + } + + #[cfg(feature = "iterative-tasks")] + #[tokio::test] + async fn test_fixed_plus_advances_from_now() { + mock_time::set(time_start()); + let (_, mut task, mut ops, _) = setup_iterative_task("weekly", Some("fixed+")).await; + mock_time::set(time_twenty_four_days_later()); + task.set_status(Status::Completed, &mut ops).unwrap(); + 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)) + ); + } + + #[cfg(feature = "iterative-tasks")] + #[tokio::test] + async fn test_chained_advances_period_from_now() { + mock_time::set(time_start()); + let (_, mut task, mut ops, _) = setup_iterative_task("weekly", None).await; + mock_time::set(time_twenty_four_days_later()); + task.set_status(Status::Completed, &mut ops).unwrap(); + 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"; diff --git a/src/task/time.rs b/src/task/time.rs index 9873dc798..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,3 +11,40 @@ 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() +} + +#[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;