Something went wrong. Try again.
forked from evanpurkhiser/things3-cloud
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651use std::{collections::BTreeMap, str::FromStr};
use std::collections::BTreeMap as ChangeMap;
use chrono::{Datelike, Days, Months, NaiveDate};use serde_json::{Value, json};
use tracing::warn;
use crate::{ common::{day_of, day_timestamp, parse_day, task6_note}, ids::ThingsId, store::{Task, ThingsStore}, wire::{ checklist::ChecklistItemProps, notes::TaskNotes, recurrence::{FrequencyUnit, RECURRENCE_END_NEVER, RecurrenceRule, RecurrenceType}, task::{TaskPatch, TaskProps, TaskStart, TaskStatus, TaskType}, wire_object::{EntityType, WireObject}, },};
/// a repeat rule as typed on the command line, such as `daily`, `weekly:mon,thu`, `monthly:15`, `monthly:last`, `yearly:12-31` or `after:2w`, with `/N` on the fixed cadences for every N units#[derive(Debug, PartialEq, Eq)]pub struct RepeatSpec { pub cadence: Cadence, pub every: i32,}
#[derive(Debug, PartialEq, Eq)]pub enum Cadence { Daily, /// weekdays counted from sunday as 0, empty for the first day's weekday Weekly(Vec<u32>), /// 1 to 31, -1 for the last day, None for the first day's day Monthly(Option<i32>), /// month and day, None for the first day's month and day Yearly(Option<(u32, u32)>), AfterCompletion(FrequencyUnit),}
pub enum Bound { Never, Until(NaiveDate), Times(i32),}
const WEEKDAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
/// the largest interval accepted from the command line or the wire////// that bound keeps every date step representablepub const MAX_EVERY: i32 = 999;
fn parse_every(text: &str) -> Option<i32> { text.parse::<i32>() .ok() .filter(|every| (1..=MAX_EVERY).contains(every))}
impl FromStr for RepeatSpec { type Err = String;
fn from_str(spec: &str) -> Result<Self, String> { let (head, selector) = match spec.split_once(':') { Some((head, selector)) => (head, Some(selector)), None => (spec, None), }; let (unit, every) = match head.split_once('/') { Some((unit, every)) => ( unit, parse_every(every).ok_or_else(|| { format!( "Invalid --repeat {spec}: expected a number from 1 to {MAX_EVERY} after /" ) })?, ), None => (head, 1), }; let cadence = match unit.to_ascii_lowercase().as_str() { "daily" if selector.is_none() => Cadence::Daily, "weekly" => Cadence::Weekly( selector .map(parse_weekdays) .transpose()? .unwrap_or_default(), ), "monthly" => Cadence::Monthly(selector.map(parse_month_day).transpose()?), "yearly" => Cadence::Yearly(selector.map(parse_month_and_day).transpose()?), "after" if every == 1 => { let (every, unit) = parse_interval(selector.ok_or_else(|| { format!("Invalid --repeat {spec}: after needs an interval such as after:2w") })?)?; return Ok(Self { cadence: Cadence::AfterCompletion(unit), every, }); } _ => { return Err(format!( "Invalid --repeat {spec}: expected daily, weekly, monthly, yearly or after" )); } }; Ok(Self { cadence, every }) }}
fn parse_weekdays(list: &str) -> Result<Vec<u32>, String> { let mut days: Vec<u32> = list .split(',') .map(|name| { WEEKDAYS .iter() .position(|day| day.eq_ignore_ascii_case(name.trim())) .map(|index| index as u32) .ok_or_else(|| { format!("Invalid weekday {name}: expected sun, mon, tue, wed, thu, fri or sat") }) }) .collect::<Result<_, _>>()?; days.sort_unstable(); days.dedup(); Ok(days)}
fn parse_month_day(day: &str) -> Result<i32, String> { if day.eq_ignore_ascii_case("last") { return Ok(-1); } day.parse::<i32>() .ok() .filter(|day| (1..=31).contains(day)) .ok_or_else(|| format!("Invalid day of month {day}: expected 1 to 31 or last"))}
fn parse_month_and_day(date: &str) -> Result<(u32, u32), String> { let invalid = || format!("Invalid yearly date {date}: expected MM-DD"); let (month, day) = date.split_once('-').ok_or_else(invalid)?; let month = month.parse::<u32>().map_err(|_| invalid())?; let day = day.parse::<u32>().map_err(|_| invalid())?; // 2024 is a leap year // that lets february 29 pass NaiveDate::from_ymd_opt(2024, month, day).ok_or_else(invalid)?; Ok((month, day))}
fn parse_interval(interval: &str) -> Result<(i32, FrequencyUnit), String> { let invalid = || { format!( "Invalid interval {interval}: expected a number from 1 to {MAX_EVERY} and d, w, m or y, such as 2w" ) }; let units = [ ("d", FrequencyUnit::Daily), ("w", FrequencyUnit::Weekly), ("m", FrequencyUnit::Monthly), ("y", FrequencyUnit::Yearly), ]; let (amount, unit) = units .into_iter() .find_map(|(suffix, unit)| interval.strip_suffix(suffix).map(|amount| (amount, unit))) .ok_or_else(invalid)?; Ok((parse_every(amount).ok_or_else(invalid)?, unit))}
/// the end condition from `--times` and `--until`pub fn bound(times: Option<i32>, until: Option<&str>) -> Result<Bound, String> { match (times, until) { (Some(_), Some(_)) => Err("--times and --until exclude each other".to_string()), (Some(times), None) if times >= 1 => Ok(Bound::Times(times)), (Some(times), None) => Err(format!("--times {times}: expected a positive number")), (None, Some(until)) => Ok(Bound::Until(parse_day(until, "--until")?)), (None, None) => Ok(Bound::Never), }}
fn weekday_index(day: NaiveDate) -> u32 { day.weekday().num_days_from_sunday()}
/// the sunday that starts the week of `day`, None before the first representable weekfn week_start(day: NaiveDate) -> Option<NaiveDate> { day.checked_sub_days(Days::new(u64::from(weekday_index(day))))}
/// the day of the month, -1 for the last////// clamped to the month's length/// None outside the representable yearsfn day_in_month(year: i32, month: u32, day: i32) -> Option<NaiveDate> { let last = NaiveDate::from_ymd_opt(year, month, 1)? .checked_add_months(Months::new(1))? .pred_opt()?; if day < 0 { return Some(last); } NaiveDate::from_ymd_opt(year, month, (day as u32).min(last.day()))}
fn minus_interval(day: NaiveDate, unit: FrequencyUnit, every: i32) -> Option<NaiveDate> { let every = every as u32; match unit { FrequencyUnit::Daily => day.checked_sub_days(Days::new(u64::from(every))), FrequencyUnit::Weekly => day.checked_sub_days(Days::new(u64::from(7 * every))), FrequencyUnit::Monthly => day.checked_sub_months(Months::new(every)), _ => day.checked_sub_months(Months::new(12 * every)), }}
fn offset(fields: &[(&str, i64)]) -> BTreeMap<String, Value> { fields .iter() .map(|(key, value)| ((*key).to_string(), Value::from(*value))) .collect()}
impl RepeatSpec { /// the first occurrence on or after `from`, or `from` itself when no day is representable pub fn first_occurrence(&self, from: NaiveDate) -> NaiveDate { let found = match &self.cadence { Cadence::Weekly(days) if !days.is_empty() => (0..7) .filter_map(|ahead| from.checked_add_days(Days::new(ahead))) .find(|day| days.contains(&weekday_index(*day))), Cadence::Monthly(Some(day)) => day_in_month(from.year(), from.month(), *day) .filter(|this_month| *this_month >= from) .or_else(|| { let next = from.checked_add_months(Months::new(1))?; day_in_month(next.year(), next.month(), *day) }), Cadence::Yearly(Some((month, day))) => day_in_month(from.year(), *month, *day as i32) .filter(|this_year| *this_year >= from) .or_else(|| day_in_month(from.year().checked_add(1)?, *month, *day as i32)), _ => Some(from), }; found.unwrap_or(from) }
/// the occurrence after `after` /// /// it counts intervals from `anchor` /// `anchor` need not be an occurrence itself /// None after completion or past the representable years pub fn next_occurrence(&self, anchor: NaiveDate, after: NaiveDate) -> Option<NaiveDate> { let every = i64::from(self.every); match &self.cadence { Cadence::Daily => { if after < anchor { return Some(anchor); } let elapsed = (after - anchor).num_days(); anchor.checked_add_days(Days::new((elapsed / every * every + every) as u64)) } Cadence::Weekly(days) => { let days = if days.is_empty() { vec![weekday_index(anchor)] } else { days.clone() }; let base = week_start(anchor)?; let start = if after < anchor { anchor } else { after.checked_add_days(Days::new(1))? }; (0..=7 * every + 7) .filter_map(|ahead| start.checked_add_days(Days::new(ahead as u64))) .find(|day| { days.contains(&weekday_index(*day)) && week_start(*day) .is_some_and(|week| ((week - base).num_days() / 7) % every == 0) }) } Cadence::Monthly(day) => { let day = day.unwrap_or(anchor.day() as i32); (0u32..) .map(|steps| { let month = anchor.checked_add_months(Months::new( steps.checked_mul(self.every as u32)?, ))?; day_in_month(month.year(), month.month(), day) }) .take_while(Option::is_some) .flatten() .find(|candidate| *candidate > after && *candidate >= anchor) } Cadence::Yearly(month_day) => { let (month, day) = month_day.unwrap_or((anchor.month(), anchor.day())); (0i32..) .map(|steps| { let year = anchor.year().checked_add(steps.checked_mul(self.every)?)?; day_in_month(year, month, day as i32) }) .take_while(Option::is_some) .flatten() .find(|candidate| *candidate > after && *candidate >= anchor) } Cadence::AfterCompletion(_) => None, } }
/// the spec and anchor behind a fixed schedule rule from the wire /// /// None for after completion and for every offset shape other than the ones the app was seen to write /// the repeat pass makes no instance of a rule the CLI cannot evaluate exactly /// a guess would put instances on the wrong days /// its projection takes the day the app recorded in tir instead pub fn from_rule(rule: &RecurrenceRule) -> Option<(Self, NaiveDate)> { if rule.recurrence_type != RecurrenceType::FixedSchedule || !(1..=MAX_EVERY).contains(&rule.frequency_amount) { return None; } let anchor = rule .interval_anchor .filter(|anchor| *anchor > 0) .or(rule.start_date) .and_then(day_of)?; fn keys(offset: &BTreeMap<String, Value>) -> Vec<&str> { offset.keys().map(String::as_str).collect() } fn field(offset: &BTreeMap<String, Value>, key: &str) -> Option<i64> { offset.get(key).and_then(Value::as_i64) } let cadence = match rule.frequency_unit { FrequencyUnit::Daily => { if !rule .offsets .iter() .all(|offset| keys(offset) == ["dy"] && field(offset, "dy") == Some(0)) { return None; } Cadence::Daily } FrequencyUnit::Weekly => { let mut days = rule .offsets .iter() .map(|offset| { (keys(offset) == ["wd"]) .then(|| field(offset, "wd")) .flatten() .filter(|day| (0..=6).contains(day)) .map(|day| day as u32) }) .collect::<Option<Vec<u32>>>()?; days.sort_unstable(); days.dedup(); Cadence::Weekly(days) } FrequencyUnit::Monthly => Cadence::Monthly(match rule.offsets.as_slice() { [] => None, [offset] if keys(offset) == ["dy"] => { let day = field(offset, "dy").filter(|day| (-1..=30).contains(day))?; Some(if day < 0 { -1 } else { day as i32 + 1 }) } _ => return None, }), FrequencyUnit::Yearly => Cadence::Yearly(match rule.offsets.as_slice() { [] => None, [offset] if keys(offset) == ["dy", "mo"] => { let day = field(offset, "dy").filter(|day| (0..=30).contains(day))?; let month = field(offset, "mo").filter(|month| (0..=11).contains(month))?; Some((month as u32 + 1, day as u32 + 1)) } _ => return None, }), FrequencyUnit::Unknown(_) => return None, }; let spec = Self { cadence, every: rule.frequency_amount, }; // an interval counts from its anchor // the anchor has to be an occurrence // otherwise the phase of the rule is unknown if spec.every > 1 && spec.first_occurrence(anchor) != anchor { return None; } Some((spec, anchor)) }
/// the wire rule as the app writes it, `sr` the day the rule was made, `ia` the first occurrence pub fn rule(&self, first: NaiveDate, today: NaiveDate, bound: Bound) -> RecurrenceRule { let (recurrence_type, frequency_unit, offsets, interval_anchor) = match &self.cadence { Cadence::Daily => ( RecurrenceType::FixedSchedule, FrequencyUnit::Daily, vec![offset(&[("dy", 0)])], day_timestamp(first), ), Cadence::Weekly(days) => { let days = if days.is_empty() { vec![weekday_index(first)] } else { days.clone() }; ( RecurrenceType::FixedSchedule, FrequencyUnit::Weekly, days.iter() .map(|day| offset(&[("wd", i64::from(*day))])) .collect(), day_timestamp(first), ) } Cadence::Monthly(day) => { let day = day.unwrap_or(first.day() as i32); let dy = if day < 0 { -1 } else { i64::from(day) - 1 }; ( RecurrenceType::FixedSchedule, FrequencyUnit::Monthly, vec![offset(&[("dy", dy)])], day_timestamp(first), ) } Cadence::Yearly(month_day) => { let (month, day) = month_day.unwrap_or((first.month(), first.day())); ( RecurrenceType::FixedSchedule, FrequencyUnit::Yearly, vec![offset(&[ ("dy", i64::from(day) - 1), ("mo", i64::from(month) - 1), ])], day_timestamp(first), ) } Cadence::AfterCompletion(unit) => { (RecurrenceType::AfterCompletion, *unit, Vec::new(), 0) } }; RecurrenceRule { recurrence_type, frequency_unit, frequency_amount: self.every, offsets, start_date: Some(day_timestamp(today)), interval_anchor: Some(interval_anchor), end_date: match bound { Bound::Until(day) => Some(day_timestamp(day)), Bound::Times(_) => None, Bound::Never => Some(RECURRENCE_END_NEVER), }, repeat_count: match bound { Bound::Times(times) => times, _ => 0, }, time_span_in_days: 0, version: 4, } }}
/// the next occurrence of a wire rule after `after`////// it honors the end day and the repeat count against the instances made so farpub fn next_occurrence_of_rule( rule: &RecurrenceRule, after: NaiveDate, instances_created: i32,) -> Option<NaiveDate> { let (spec, anchor) = RepeatSpec::from_rule(rule)?; let next = spec.next_occurrence(anchor, after)?; rule_reaches(rule, next, instances_created).then_some(next)}
/// whether the end day and the repeat count of a wire rule leave room for an instance on `day`, whatever its cadencepub fn rule_reaches(rule: &RecurrenceRule, day: NaiveDate, instances_created: i32) -> bool { if rule.repeat_count > 0 && instances_created >= rule.repeat_count { return false; } match rule.end_date { Some(end) if end != RECURRENCE_END_NEVER => day_of(end).is_some_and(|end| day <= end), _ => true, }}
/// an instance a template is due for on `day`, the shape the app writes when it creates the next copypub struct Materialized { pub title: String, pub day: NaiveDate, pub instance_id: String, pub changes: ChangeMap<String, WireObject>,}
/// the instances whose day has come, for every fixed schedule template not yet served for that day////// `icsd` on a template is the day the search for the next instance starts from, not the day the next instance is due/// the app sets it to the next occurrence when it makes the template and to the day after each instance it creates/// the due day is therefore the first occurrence on or after it/// bounded by the rule's end day and count/// a template past its end is never duepub fn due_instances( store: &ThingsStore, today: NaiveDate, now: f64, next_id: &mut dyn FnMut() -> String,) -> Vec<Materialized> { let mut templates: Vec<&Task> = store .tasks_by_uuid .values() .filter(|template| { template.is_recurrence_template() && !template.trashed && template.status == TaskStatus::Incomplete && !template.instance_creation_paused // a template whose replay failed is never written through && !template.degraded // a repeater is left to the Apple clients // the commands leave it to them too && !template.has_repeater() // a template in a closed or trashed project or heading makes nothing // its to-dos show nowhere && !store.in_closed_container(template) // a template in a project or heading whose replay failed makes nothing either // that container may be closed or in the Trash without showing it // a container missing from the store failed to replay or was never seen // an item of another kind and the template of a repeating project take no to-do && !template .action_group .iter() .cloned() .chain(store.effective_project_uuid(template)) .any(|id| { store.tasks_by_uuid.get(&id).is_none_or(|container| { container.degraded || container.is_recurrence_template() || !(container.is_project() || container.is_heading()) }) }) // an area or a tag whose replay failed takes no instance // an area missing from the store failed to replay or was never seen && template.area.as_ref().is_none_or(|id| { store .areas_by_uuid .get(id) .is_some_and(|area| !area.degraded) }) && !template .tags .iter() .any(|id| store.tags_by_uuid.get(id).is_some_and(|tag| tag.degraded)) // a template with a deadline is left to the Apple clients // the app keeps an instance's deadline as an offset no capture has shown written && template.deadline.is_none() // a repeating project is left to the Apple clients too // its instance carries the project's to-dos // no capture has shown those written // a template of a kind this CLI does not know is left to them as well && template.is_todo() && template.due_date_offset == 0 }) .collect(); templates.sort_by(|a, b| a.uuid.cmp(&b.uuid)); templates .into_iter() .filter_map(|template| { let rule = template.recurrence_rule.as_ref()?; let search_from = template.instance_creation_start_date.and_then(day_of)?; let due = next_occurrence_of_rule( rule, search_from.pred_opt()?, template.instance_creation_count, )?; if due > today { return None; } // the app keeps tir on the day of the instance it will create next // a template whose tir names another day is read differently by the app and left to it if let Some(shown) = template.today_index_reference.and_then(day_of) && shown != due { warn!(target: "things::replay", uuid = %template.uuid, %shown, %due, "tir is not the day the rule yields, the template is left alone"); return None; } let Some(created) = template.instance_creation_count.checked_add(1) else { warn!(target: "things::replay", uuid = %template.uuid, "the instance count cannot advance, the template is left alone"); return None; }; // the search resumes tomorrow // that makes a missed stretch yield this one instance and not one per missed day let resume = today.succ_opt()?; let following = next_occurrence_of_rule(rule, today, created); let instance_id = next_id(); let instance_uuid: ThingsId = instance_id .parse() .expect("the id generator yields Things ids"); let day_ts = day_timestamp(due); let instance = TaskProps { title: template.title.clone(), notes: template.notes.as_deref().map(task6_note), item_type: TaskType::Todo, status: TaskStatus::Incomplete, start_location: TaskStart::Anytime, scheduled_date: Some(day_ts), today_index_reference: Some(day_ts), tag_ids: template.tags.clone(), parent_project_ids: template.project.iter().cloned().collect(), area_ids: template.area.iter().cloned().collect(), action_group_ids: template.action_group.iter().cloned().collect(), sort_index: template.index, today_sort_index: template.today_index, recurrence_template_ids: vec![template.uuid.clone()], alarm_time_offset: template.alarm_time_offset, conflict_overrides: Some(json!({"_t": "oo", "sn": {}})), creation_date: Some(now), modification_date: Some(now), ..Default::default() }; let advance = TaskPatch { instance_creation_count: Some(created), instance_creation_start_date: Some(Some(day_timestamp(resume))), today_index_reference: Some(following.map(day_timestamp)), modification_date: Some(Some(now)), ..Default::default() }; let mut changes = ChangeMap::new(); changes.insert( instance_id.clone(), WireObject::create(EntityType::Task7, instance), ); changes.insert( template.uuid.to_string(), WireObject::update(EntityType::Task7, advance), ); // the instance gets its own copy of the template's checklist // each item of the copy starts incomplete let checklist = template .checklist_items .iter() .map(|item| ChecklistCopy { title: item.title.clone(), index: item.index, }) .collect::<Vec<_>>(); changes.extend(checklist_items(&checklist, &instance_uuid, now, next_id)); Some(Materialized { title: template.title.clone(), day: due, instance_id, changes, }) }) .collect()}
/// what the template copies from the to-do that becomes its first instancepub struct TemplateSource { pub title: String, pub notes: Option<TaskNotes>, pub tag_ids: Vec<ThingsId>, pub parent_project_ids: Vec<ThingsId>, pub area_ids: Vec<ThingsId>, pub action_group_ids: Vec<ThingsId>, pub alarm_time_offset: Option<i64>, pub sort_index: i32, pub today_sort_index: i32, pub conflict_overrides: Option<Value>,}
/// one checklist item to copy////// every copy starts incompletepub struct ChecklistCopy { pub title: String, pub index: i32,}
/// fresh checklist items for `owner`, one create per copy////// `checklist` comes in the order it is shown/// copies of items that share an index take distinct ones in that order/// the fresh ids would otherwise decide their orderpub fn checklist_items( checklist: &[ChecklistCopy], owner: &ThingsId, now: f64, next_id: &mut dyn FnMut() -> String,) -> Vec<(String, WireObject)> { let mut previous: Option<i32> = None; checklist .iter() .map(|item| { let index = previous.map_or(item.index, |last| item.index.max(last.saturating_add(1))); previous = Some(index); ( next_id(), WireObject::create( EntityType::ChecklistItem3, ChecklistItemProps { title: item.title.clone(), task_ids: vec![owner.clone()], status: TaskStatus::Incomplete, sort_index: index, creation_date: Some(now), modification_date: Some(now), ..Default::default() }, ), ) }) .collect()}
/// the hidden template behind a repeating to-do whose first instance is on `first`////// `icsd` points at the occurrence after the first/// the app writes it that way/// it points at the day after the first instead when the count or the end day allows only the one instance/// `tir` is that occurrence or nothingpub fn template( spec: &RepeatSpec, rule: RecurrenceRule, first: NaiveDate, source: TemplateSource, now: f64,) -> Option<TaskProps> { let day_after_first = first.succ_opt()?; let next = match &spec.cadence { Cadence::AfterCompletion(_) => Some(day_after_first), _ => next_occurrence_of_rule(&rule, first, 1), }; let after_completion_reference_date = match &spec.cadence { Cadence::AfterCompletion(unit) => { minus_interval(first, *unit, spec.every).map(day_timestamp) } _ => None, }; Some(TaskProps { title: source.title, notes: source.notes, start_location: TaskStart::Someday, scheduled_date: None, today_index_reference: next.map(day_timestamp), tag_ids: source.tag_ids, parent_project_ids: source.parent_project_ids, area_ids: source.area_ids, action_group_ids: source.action_group_ids, sort_index: source.sort_index, today_sort_index: source.today_sort_index, recurrence_rule: Some(rule), instance_creation_start_date: Some(day_timestamp(next.unwrap_or(day_after_first))), instance_creation_count: 1, instance_creation_paused: false, after_completion_reference_date, alarm_time_offset: source.alarm_time_offset, conflict_overrides: source.conflict_overrides, creation_date: Some(now), modification_date: Some(now), ..Default::default() })}
#[cfg(test)]mod tests { use super::*; use crate::{ store::fold_items, wire::{task::TaskProps, wire_object::WireItem}, };
fn day(text: &str) -> NaiveDate { NaiveDate::parse_from_str(text, "%Y-%m-%d").expect("date") }
#[test] fn copied_checklist_items_keep_the_order_they_were_shown_in() { let copies = [ ("passport", 0), ("charger", 0), ("keys", 0), ("wallet", 5), ("map", 5), ] .map(|(title, index)| ChecklistCopy { title: title.to_string(), index, }); let owner = "Ta11111111111111111111".parse::<ThingsId>().expect("id"); // ids that sort against the order shown let mut ids = ["Tz1", "Ty1", "Tx1", "Tw1", "Tv1"] .into_iter() .map(str::to_string); let items = checklist_items(&copies, &owner, 1.0, &mut || ids.next().expect("id")); let indexes = items .iter() .map(|(_, object)| serde_json::to_value(object).expect("json")["p"]["ix"].clone()) .collect::<Vec<_>>(); assert_eq!(indexes, [0, 1, 2, 5, 6].map(serde_json::Value::from)); }
fn spec(text: &str) -> RepeatSpec { text.parse().expect("spec") }
fn rule(json: &str) -> RecurrenceRule { serde_json::from_str(json).expect("rule") }
// a yearly rule on september 17 that ends on 2027-09-17 // the app wrote it that way const YEARLY_SEP_17: &str = r#"{"ed":1821139200,"fa":1,"fu":4,"ia":1789603200,"of":[{"dy":16,"mo":8}],"rc":0,"rrv":4,"sr":1789603200,"tp":0,"ts":0}"#; // a daily rule anchored on 2026-03-16 that ends on 2026-03-25 const DAILY_UNTIL_MAR_25: &str = r#"{"ed":1774396800,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; const TEMPLATE: &str = "Tt11111111111111111111"; const INSTANCE: &str = "Ji11111111111111111111";
fn template_object( rule_json: &str, search_from: &str, instances_created: i32, ) -> (String, WireObject) { ( TEMPLATE.to_string(), WireObject::create( EntityType::Task7, TaskProps { title: "Renew".to_string(), start_location: TaskStart::Someday, recurrence_rule: Some(rule(rule_json)), instance_creation_start_date: Some(day_timestamp(day(search_from))), instance_creation_count: instances_created, creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
/// a template as the app leaves it, `tir` on the day of the next instance fn template_shown_on(rule_json: &str, search_from: &str, shown: &str) -> (String, WireObject) { ( TEMPLATE.to_string(), WireObject::create( EntityType::Task7, TaskProps { title: "Renew".to_string(), start_location: TaskStart::Someday, recurrence_rule: Some(rule(rule_json)), instance_creation_start_date: Some(day_timestamp(day(search_from))), today_index_reference: Some(day_timestamp(day(shown))), creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
fn instance_object(uuid: &str, on: &str) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::Task7, TaskProps { title: "Renew".to_string(), start_location: TaskStart::Anytime, scheduled_date: Some(day_timestamp(day(on))), recurrence_template_ids: vec![TEMPLATE.parse().expect("template id")], creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
fn store_of(objects: Vec<(String, WireObject)>) -> ThingsStore { ThingsStore::from_raw_state(&fold_items([objects.into_iter().collect::<WireItem>()])) }
/// the nth id the test generator hands out fn id(n: u128) -> String { ThingsId::from_u128(n).to_string() }
fn due_on(store: &ThingsStore, today: &str) -> Vec<Materialized> { let mut ids = (1..).map(id); let mut next_id = || ids.next().expect("id"); due_instances(store, day(today), 2.0, &mut next_id) }
#[test] fn a_tir_on_another_day_than_the_rule_yields_leaves_the_template_alone() { let agreeing = store_of(vec![template_shown_on( DAILY_UNTIL_MAR_25, "2026-03-20", "2026-03-20", )]); assert_eq!(due_on(&agreeing, "2026-03-25").len(), 1); let disagreeing = store_of(vec![template_shown_on( DAILY_UNTIL_MAR_25, "2026-03-20", "2026-03-22", )]); assert!(due_on(&disagreeing, "2026-03-25").is_empty()); }
#[test] fn a_count_that_cannot_advance_leaves_the_template_alone() { let counting = store_of(vec![template_object(DAILY_UNTIL_MAR_25, "2026-03-20", 0)]); assert_eq!(due_on(&counting, "2026-03-25").len(), 1); let exhausted = store_of(vec![template_object( DAILY_UNTIL_MAR_25, "2026-03-20", i32::MAX, )]); assert!(due_on(&exhausted, "2026-03-25").is_empty()); }
fn template_patch(made: &Materialized) -> BTreeMap<String, Value> { made.changes .get(TEMPLATE) .expect("the template advances") .properties_map() }
#[test] fn parses_every_form() { assert_eq!( spec("daily/3"), RepeatSpec { cadence: Cadence::Daily, every: 3 } ); assert_eq!( spec("weekly:thu,mon"), RepeatSpec { cadence: Cadence::Weekly(vec![1, 4]), every: 1 } ); assert_eq!( spec("monthly:last"), RepeatSpec { cadence: Cadence::Monthly(Some(-1)), every: 1 } ); assert_eq!( spec("yearly/2:02-29"), RepeatSpec { cadence: Cadence::Yearly(Some((2, 29))), every: 2 } ); assert_eq!( spec("after:2w"), RepeatSpec { cadence: Cadence::AfterCompletion(FrequencyUnit::Weekly), every: 2 } ); assert!("daily:mon".parse::<RepeatSpec>().is_err()); assert!("monthly:32".parse::<RepeatSpec>().is_err()); assert!("after".parse::<RepeatSpec>().is_err()); // intervals past the bound and suffixes that are not a unit are errors, not panics assert!("daily/2147483647".parse::<RepeatSpec>().is_err()); assert!("daily/1000".parse::<RepeatSpec>().is_err()); assert!("daily/0".parse::<RepeatSpec>().is_err()); assert!("after:2w\u{e9}".parse::<RepeatSpec>().is_err()); assert!("after:\u{e9}".parse::<RepeatSpec>().is_err()); assert!("after:2147483648d".parse::<RepeatSpec>().is_err()); assert_eq!(spec("daily/999").every, 999); }
#[test] fn occurrences_past_the_representable_years_are_none() { let far = NaiveDate::MAX; assert_eq!(spec("daily").next_occurrence(far, far), None); assert_eq!(spec("monthly:15").next_occurrence(far, far), None); assert_eq!(spec("yearly:12-31").next_occurrence(far, far), None); assert_eq!(spec("weekly:mon").next_occurrence(far, far), None); assert_eq!(spec("monthly:15").first_occurrence(far), far); // a wire rule with an interval past the bound is not evaluated assert!( RepeatSpec::from_rule(&rule( r#"{"fa":1000,"fu":16,"ia":1789603200,"of":[{"dy":0}],"rc":0,"sr":1789603200,"tp":0}"# )) .is_none() ); }
#[test] fn first_occurrence_moves_to_the_selector() { // 2026-09-17 is a thursday assert_eq!( spec("weekly:mon,thu").first_occurrence(day("2026-09-17")), day("2026-09-17") ); assert_eq!( spec("weekly:mon").first_occurrence(day("2026-09-17")), day("2026-09-21") ); assert_eq!( spec("monthly:15").first_occurrence(day("2026-09-17")), day("2026-10-15") ); assert_eq!( spec("monthly:last").first_occurrence(day("2026-09-17")), day("2026-09-30") ); assert_eq!( spec("yearly:12-31").first_occurrence(day("2026-09-17")), day("2026-12-31") ); assert_eq!( spec("yearly:01-05").first_occurrence(day("2026-09-17")), day("2027-01-05") ); assert_eq!( spec("daily").first_occurrence(day("2026-09-17")), day("2026-09-17") ); }
#[test] fn next_occurrence_counts_from_the_anchor() { let thursday = day("2026-09-17"); assert_eq!( spec("daily/3").next_occurrence(thursday, thursday), Some(day("2026-09-20")) ); assert_eq!( spec("weekly:mon,thu").next_occurrence(thursday, thursday), Some(day("2026-09-21")) ); assert_eq!( spec("weekly/2").next_occurrence(thursday, thursday), Some(day("2026-10-01")) ); assert_eq!( spec("weekly/2:mon,thu").next_occurrence(thursday, thursday), Some(day("2026-09-28")) ); assert_eq!( spec("monthly:31").next_occurrence(day("2026-01-31"), day("2026-01-31")), Some(day("2026-02-28")) ); assert_eq!( spec("monthly:last").next_occurrence(day("2026-09-30"), day("2026-09-30")), Some(day("2026-10-31")) ); assert_eq!( spec("yearly:02-29").next_occurrence(day("2024-02-29"), day("2024-02-29")), Some(day("2025-02-28")) ); assert_eq!(spec("after:2w").next_occurrence(thursday, thursday), None); }
#[test] fn wire_rules_evaluate_like_the_app() { let today = day("2026-09-17"); // the app's monthly rule anchored on its creation day, first occurrence october 15 let monthly: RecurrenceRule = serde_json::from_str( r#"{"fa":1,"fu":8,"ia":1789603200,"of":[{"dy":14}],"rc":3,"rrv":4,"sr":1789603200,"tp":0,"ts":0}"#, ) .expect("rule"); assert_eq!( next_occurrence_of_rule(&monthly, today - Days::new(1), 0), Some(day("2026-10-15")) ); assert_eq!( next_occurrence_of_rule(&monthly, day("2026-10-15"), 3), None ); // yearly ending on its own day let yearly: RecurrenceRule = serde_json::from_str( r#"{"ed":1821139200,"fa":1,"fu":4,"ia":1789603200,"of":[{"dy":16,"mo":8}],"rc":0,"rrv":4,"sr":1789603200,"tp":0,"ts":0}"#, ) .expect("rule"); assert_eq!( next_occurrence_of_rule(&yearly, today, 1), Some(day("2027-09-17")) ); assert_eq!(next_occurrence_of_rule(&yearly, day("2027-09-17"), 2), None); // after completion is never projected let after: RecurrenceRule = serde_json::from_str( r#"{"ed":64092211200,"fa":2,"fu":256,"ia":0,"of":[],"rc":0,"rrv":4,"sr":1789603200,"tp":1,"ts":0}"#, ) .expect("rule"); assert_eq!(next_occurrence_of_rule(&after, today, 0), None); }
#[test] fn rule_matches_the_captured_shapes() { let today = day("2026-09-17"); let weekly = spec("weekly:mon,thu").rule(today, today, Bound::Never); assert_eq!( weekly.offsets, vec![offset(&[("wd", 1)]), offset(&[("wd", 4)])] ); assert_eq!(weekly.end_date, Some(RECURRENCE_END_NEVER)); assert_eq!(weekly.interval_anchor, Some(1789603200));
let monthly = spec("monthly:15").rule(day("2026-10-15"), today, Bound::Times(3)); assert_eq!(monthly.offsets, vec![offset(&[("dy", 14)])]); assert_eq!(monthly.end_date, None); assert_eq!(monthly.repeat_count, 3);
let yearly = spec("yearly:09-17").rule(today, today, Bound::Until(day("2027-09-17"))); assert_eq!(yearly.offsets, vec![offset(&[("dy", 16), ("mo", 8)])]); assert_eq!(yearly.end_date, Some(1821139200));
let last = spec("monthly:last").rule(day("2026-09-30"), today, Bound::Never); assert_eq!(last.offsets, vec![offset(&[("dy", -1)])]);
let after = spec("after:2w").rule(day("2027-12-31"), today, Bound::Never); assert_eq!(after.recurrence_type, RecurrenceType::AfterCompletion); assert_eq!(after.interval_anchor, Some(0)); assert!(after.offsets.is_empty()); }
#[test] fn from_rule_declines_shapes_it_cannot_evaluate() { let evaluates = |json: &str| RepeatSpec::from_rule(&rule(json)).is_some(); // the captured shapes assert!(evaluates( r#"{"fa":1,"fu":16,"ia":1789603200,"of":[{"dy":0}],"rc":0,"sr":1789603200,"tp":0}"# )); assert!(evaluates( r#"{"fa":2,"fu":256,"ia":1789603200,"of":[{"wd":1},{"wd":4}],"rc":0,"sr":1789603200,"tp":0}"# )); assert!(evaluates( r#"{"fa":1,"fu":8,"ia":1789603200,"of":[{"dy":-1}],"rc":0,"sr":1789603200,"tp":0}"# )); assert!(evaluates(YEARLY_SEP_17)); // the last thursday of november, a weekday ordinal the CLI does not compute assert!(!evaluates( r#"{"fa":1,"fu":4,"ia":1767225600,"of":[{"mo":10,"wd":4,"wdo":-1}],"rc":0,"sr":1767225600,"tp":0}"# )); assert!(!evaluates( r#"{"fa":1,"fu":8,"ia":1789603200,"of":[{"wd":5,"wdo":-1}],"rc":0,"sr":1789603200,"tp":0}"# )); // two days a month // a single day selector cannot carry that assert!(!evaluates( r#"{"fa":1,"fu":8,"ia":1789603200,"of":[{"dy":0},{"dy":14}],"rc":0,"sr":1789603200,"tp":0}"# )); // values outside the app's ranges and keys it never writes assert!(!evaluates( r#"{"fa":1,"fu":256,"ia":1789603200,"of":[{"wd":7}],"rc":0,"sr":1789603200,"tp":0}"# )); assert!(!evaluates( r#"{"fa":1,"fu":4,"ia":1789603200,"of":[{"dy":16,"mo":12}],"rc":0,"sr":1789603200,"tp":0}"# )); assert!(!evaluates( r#"{"fa":1,"fu":16,"ia":1789603200,"of":[{"dy":3}],"rc":0,"sr":1789603200,"tp":0}"# )); assert!(!evaluates( r#"{"fa":0,"fu":16,"ia":1789603200,"of":[{"dy":0}],"rc":0,"sr":1789603200,"tp":0}"# )); }
#[test] fn an_unsupported_rule_is_never_materialized() { let store = store_of(vec![template_object( r#"{"fa":1,"fu":4,"ia":1767225600,"of":[{"mo":10,"wd":4,"wdo":-1}],"rc":0,"sr":1767225600,"tp":0}"#, "2026-11-26", 1, )]); assert!(due_on(&store, "2026-11-27").is_empty()); assert!(store.projected_repeats(day("2026-11-27")).is_empty()); }
#[test] fn the_day_after_an_app_made_instance_is_not_due() { // the app created the 2026-09-17 instance and moved icsd to the next day let store = store_of(vec![ template_object(YEARLY_SEP_17, "2026-09-18", 1), instance_object(INSTANCE, "2026-09-17"), ]); assert!(due_on(&store, "2026-09-18").is_empty()); assert!(due_on(&store, "2027-09-16").is_empty());
let made = due_on(&store, "2027-09-17"); assert_eq!(made.len(), 1); assert_eq!(made[0].day, day("2027-09-17")); let patch = template_patch(&made[0]); assert_eq!(patch.get("icc"), Some(&json!(2))); assert_eq!( patch.get("icsd"), Some(&json!(day_timestamp(day("2027-09-18")))) ); // the rule ended with this occurrence assert_eq!(patch.get("tir"), Some(&Value::Null)); }
#[test] fn the_last_allowed_day_creates_its_instance_and_no_day_after_it() { let store = store_of(vec![ template_object(DAILY_UNTIL_MAR_25, "2026-03-25", 1), instance_object(INSTANCE, "2026-03-24"), ]); let made = due_on(&store, "2026-03-25"); assert_eq!(made.len(), 1); assert_eq!(made[0].day, day("2026-03-25")); let patch = template_patch(&made[0]); assert_eq!( patch.get("icsd"), Some(&json!(day_timestamp(day("2026-03-26")))) ); assert_eq!(patch.get("tir"), Some(&Value::Null));
// the day after, with that commit folded in let mut state = fold_items([[ template_object(DAILY_UNTIL_MAR_25, "2026-03-25", 1), instance_object(INSTANCE, "2026-03-24"), ] .into_iter() .collect::<WireItem>()]); crate::store::fold_item(made.into_iter().next().expect("made").changes, &mut state); let store = ThingsStore::from_raw_state(&state); assert!(due_on(&store, "2026-03-26").is_empty()); assert!(due_on(&store, "2026-04-01").is_empty());
// a template an earlier version advanced past its end day let store = store_of(vec![template_object(DAILY_UNTIL_MAR_25, "2026-03-26", 2)]); assert!(due_on(&store, "2026-03-26").is_empty()); }
#[test] fn a_rescheduled_instance_does_not_hold_the_template_back() { let daily = r#"{"ed":64092211200,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; // the user moved the instance of 2026-03-24 to 2026-03-27 // the search still starts on 03-25 let store = store_of(vec![ template_object(daily, "2026-03-25", 2), instance_object(INSTANCE, "2026-03-27"), ]); let made = due_on(&store, "2026-03-25"); assert_eq!(made.len(), 1); assert_eq!(made[0].day, day("2026-03-25")); }
#[test] fn a_template_in_a_closed_project_makes_nothing() { let daily = r#"{"ed":64092211200,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; const PROJECT: &str = "Pj11111111111111111111"; let with_project = |status: TaskStatus| { let (uuid, mut object) = template_object(daily, "2026-03-25", 1); if let crate::wire::wire_object::Properties::TaskCreate(props) = &mut object.payload { props.parent_project_ids = vec![PROJECT.parse().expect("project id")]; } let project = WireObject::create( EntityType::Task7, TaskProps { title: "Garden season".to_string(), item_type: TaskType::Project, status, ..Default::default() }, ); store_of(vec![(uuid, object), (PROJECT.to_string(), project)]) }; assert_eq!( due_on(&with_project(TaskStatus::Incomplete), "2026-03-25").len(), 1 ); assert!(due_on(&with_project(TaskStatus::Completed), "2026-03-25").is_empty()); }
#[test] fn a_template_in_a_project_whose_replay_failed_makes_nothing() { let daily = r#"{"ed":64092211200,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; const PROJECT: &str = "Pj11111111111111111111"; let in_project = |project: &str| { let (uuid, mut object) = template_object(daily, "2026-03-25", 1); if let crate::wire::wire_object::Properties::TaskCreate(props) = &mut object.payload { props.parent_project_ids = vec![PROJECT.parse().expect("project id")]; } let project: WireObject = serde_json::from_str(project).expect("project"); store_of(vec![(uuid, object), (PROJECT.to_string(), project)]) }; // a field of the project that does not parse marks it let marked = in_project( r#"{"t":0,"e":"Task7","p":{"tt":"Garden season","tp":1,"st":1,"md":"2026-03-24"}}"#, ); assert!(due_on(&marked, "2026-03-25").is_empty()); // a payload that is no object leaves the project out of the store let missing = in_project(r#"{"t":0,"e":"Task7","p":"garbled"}"#); assert!( !missing .tasks_by_uuid .contains_key(&PROJECT.parse().expect("project id")) ); assert!(due_on(&missing, "2026-03-25").is_empty()); }
#[test] fn a_template_where_the_commands_put_no_to_do_makes_nothing() { let daily = r#"{"ed":64092211200,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; const HOLDER: &str = "Pj11111111111111111111"; let placed = |place: fn(&mut TaskProps, ThingsId), holder: &str| { let (uuid, mut object) = template_object(daily, "2026-03-25", 1); if let crate::wire::wire_object::Properties::TaskCreate(props) = &mut object.payload { place(props, HOLDER.parse().expect("holder id")); } let holder: WireObject = serde_json::from_str(holder).expect("holder"); store_of(vec![(uuid, object), (HOLDER.to_string(), holder)]) }; let in_project: fn(&mut TaskProps, ThingsId) = |props, id| props.parent_project_ids = vec![id]; let in_area: fn(&mut TaskProps, ThingsId) = |props, id| props.area_ids = vec![id]; let tagged: fn(&mut TaskProps, ThingsId) = |props, id| props.tag_ids = vec![id]; // the template of a repeating project and an item of an unknown kind hold no to-do let project_template = format!( r#"{{"t":0,"e":"Task7","p":{{"tt":"Garden season","tp":1,"st":1,"rr":{daily}}}}}"# ); assert!(due_on(&placed(in_project, &project_template), "2026-03-25").is_empty()); let unknown_kind = r#"{"t":0,"e":"Task7","p":{"tt":"Odd","tp":9,"st":1}}"#; assert!(due_on(&placed(in_project, unknown_kind), "2026-03-25").is_empty()); // an area that replayed takes the instance // one whose field does not parse and one missing from the store take none let area = r#"{"t":0,"e":"Area3","p":{"tt":"Home"}}"#; assert_eq!(due_on(&placed(in_area, area), "2026-03-25").len(), 1); let marked_area = r#"{"t":0,"e":"Area3","p":{"tt":"Home","ix":"first"}}"#; assert!(due_on(&placed(in_area, marked_area), "2026-03-25").is_empty()); let missing_area = r#"{"t":0,"e":"Area3","p":"garbled"}"#; assert!(due_on(&placed(in_area, missing_area), "2026-03-25").is_empty()); // a tag whose field does not parse goes on no instance let tag = r#"{"t":0,"e":"Tag4","p":{"tt":"Chores"}}"#; assert_eq!(due_on(&placed(tagged, tag), "2026-03-25").len(), 1); let marked_tag = r#"{"t":0,"e":"Tag4","p":{"tt":"Chores","ix":"first"}}"#; assert!(due_on(&placed(tagged, marked_tag), "2026-03-25").is_empty()); }
#[test] fn upcoming_shows_the_instance_the_template_makes_next() { let daily = r#"{"ed":64092211200,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; let projected = |store: &ThingsStore| { store .projected_repeats(day("2026-03-25")) .iter() .map(|task| task.start_date.expect("day").date_naive()) .collect::<Vec<_>>() }; // today's instance moved five days out holds nothing back // the template makes tomorrow's let moved = store_of(vec![ template_object(daily, "2026-03-26", 1), instance_object("Ji11111111111111111111", "2026-03-30"), ]); assert_eq!(projected(&moved), vec![day("2026-03-26")]); // an instance due today and not yet made belongs to today // the row shows the next one let due = store_of(vec![template_object(daily, "2026-03-25", 0)]); assert_eq!(projected(&due), vec![day("2026-03-26")]); // a blank template projects a blank row // the lists leave that row out let (uuid, mut blank) = template_object(daily, "2026-03-26", 1); if let crate::wire::wire_object::Properties::TaskCreate(props) = &mut blank.payload { props.title = String::new(); } assert!(projected(&store_of(vec![(uuid, blank)])).is_empty()); // every other day from 2026-03-16 the rule yields 2026-03-26 // a tir on 2026-03-27 is the day the app makes the instance let every_other_day = r#"{"ed":64092211200,"fa":2,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; let shown_later = store_of(vec![template_shown_on( every_other_day, "2026-03-26", "2026-03-27", )]); assert_eq!(projected(&shown_later), vec![day("2026-03-27")]); // from that day on the day of the next one is unknown assert!(shown_later.projected_repeats(day("2026-03-27")).is_empty()); // a fixed schedule this CLI cannot evaluate shows on its tir while that day is ahead // the last thursday of every month is one let last_thursday = r#"{"ed":64092211200,"fa":1,"fu":8,"ia":1773619200,"of":[{"wd":4,"wdo":-1}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; let unevaluated = store_of(vec![template_shown_on( last_thursday, "2026-03-26", "2026-03-26", )]); assert_eq!(projected(&unevaluated), vec![day("2026-03-26")]); assert!(unevaluated.projected_repeats(day("2026-03-26")).is_empty()); // the tir of an after completion rule shows nothing let after_completion = r#"{"ed":64092211200,"fa":2,"fu":256,"ia":1773619200,"of":[{"wd":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":1,"ts":0}"#; assert!( projected(&store_of(vec![template_shown_on( after_completion, "2026-03-26", "2026-03-27", )])) .is_empty() ); }
#[test] fn a_marked_or_dated_template_is_left_to_the_apple_clients() { let daily = r#"{"ed":64092211200,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; // a patch that does not parse marks the template let mut items: Vec<WireItem> = vec![ [template_object(daily, "2026-03-25", 1)] .into_iter() .collect(), ]; items.push( serde_json::from_str(&format!( r#"{{"{TEMPLATE}":{{"t":1,"e":"Task7","p":{{"ss":"future"}}}}}}"# )) .expect("item"), ); let store = ThingsStore::from_raw_state(&fold_items(items)); assert!(store.get_task(TEMPLATE).expect("template").degraded); assert!(due_on(&store, "2026-03-25").is_empty());
// an update of a kind or an operation this CLI does not read marks it too for update in [ format!(r#"{{"{TEMPLATE}":{{"t":1,"e":"Task8","p":{{"tr":true}}}}}}"#), format!(r#"{{"{TEMPLATE}":{{"t":3,"e":"Task7","p":{{}}}}}}"#), ] { let items: Vec<WireItem> = vec![ [template_object(daily, "2026-03-25", 1)] .into_iter() .collect(), serde_json::from_str(&update).expect("item"), ]; let store = ThingsStore::from_raw_state(&fold_items(items)); assert!( store.get_task(TEMPLATE).expect("template").degraded, "{update}" ); assert!(due_on(&store, "2026-03-25").is_empty(), "{update}"); }
// a repeater carries bookkeeping this CLI does not read let (uuid, mut object) = template_object(daily, "2026-03-25", 1); if let crate::wire::wire_object::Properties::TaskCreate(props) = &mut object.payload { props.repeater = Some(json!({"fu": 16})); } assert!(due_on(&store_of(vec![(uuid, object)]), "2026-03-25").is_empty());
// a deadline, absolute or as an offset, the CLI cannot carry onto an instance yet for (deadline, offset) in [(Some(day_timestamp(day("2026-03-27"))), 0), (None, 2)] { let (uuid, mut object) = template_object(daily, "2026-03-25", 1); if let crate::wire::wire_object::Properties::TaskCreate(props) = &mut object.payload { props.deadline = deadline; props.due_date_offset = offset; } let store = store_of(vec![(uuid, object)]); assert!(due_on(&store, "2026-03-25").is_empty()); } }
#[test] fn an_interval_rule_counts_from_an_anchor_that_is_an_occurrence() { // every two weeks on monday, anchored on a thursday // the phase is unknown assert!( RepeatSpec::from_rule(&rule( r#"{"fa":2,"fu":256,"ia":1789603200,"of":[{"wd":1}],"rc":0,"sr":1789603200,"tp":0}"# )) .is_none() ); // anchored on a monday it counts from there assert!( RepeatSpec::from_rule(&rule( r#"{"fa":2,"fu":256,"ia":1789948800,"of":[{"wd":1}],"rc":0,"sr":1789603200,"tp":0}"# )) .is_some() ); // every week the phase does not matter assert!( RepeatSpec::from_rule(&rule( r#"{"fa":1,"fu":256,"ia":1789603200,"of":[{"wd":1}],"rc":0,"sr":1789603200,"tp":0}"# )) .is_some() ); // every third month on the 22nd, anchored on the 17th assert!( RepeatSpec::from_rule(&rule( r#"{"fa":3,"fu":8,"ia":1789603200,"of":[{"dy":21}],"rc":0,"sr":1789603200,"tp":0}"# )) .is_none() ); // every two days counts from any anchor assert!( RepeatSpec::from_rule(&rule( r#"{"fa":2,"fu":16,"ia":1789603200,"of":[{"dy":0}],"rc":0,"sr":1789603200,"tp":0}"# )) .is_some() ); }
#[test] fn an_instance_gets_a_fresh_copy_of_the_template_checklist() { let daily = r#"{"ed":64092211200,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; let item_id = "Ck11111111111111111111"; let store = store_of(vec![ template_object(daily, "2026-03-25", 1), ( item_id.to_string(), WireObject::create( EntityType::ChecklistItem3, ChecklistItemProps { title: "Stretch".to_string(), task_ids: vec![TEMPLATE.parse().expect("id")], status: TaskStatus::Completed, sort_index: 7, ..Default::default() }, ), ), ]); let made = due_on(&store, "2026-03-25"); assert_eq!(made.len(), 1); let copy = made[0] .changes .get(&id(2)) .expect("the copy takes the id after the instance") .properties_map(); assert_eq!(copy.get("tt"), Some(&json!("Stretch"))); assert_eq!(copy.get("ts"), Some(&json!([id(1)]))); assert_eq!(copy.get("ss"), Some(&json!(0))); assert_eq!(copy.get("ix"), Some(&json!(7))); assert!(!made[0].changes.contains_key(item_id)); }
#[test] fn a_missed_stretch_yields_one_instance_on_the_first_missed_day() { let daily = r#"{"ed":64092211200,"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":1773619200,"tp":0,"ts":0}"#; let store = store_of(vec![template_object(daily, "2026-03-20", 1)]); let made = due_on(&store, "2026-03-25"); assert_eq!(made.len(), 1); assert_eq!(made[0].day, day("2026-03-20")); let patch = template_patch(&made[0]); assert_eq!( patch.get("icsd"), Some(&json!(day_timestamp(day("2026-03-26")))) ); assert_eq!( patch.get("tir"), Some(&json!(day_timestamp(day("2026-03-26")))) );
// the count bounds the search as well as the end day let store = store_of(vec![template_object( r#"{"fa":1,"fu":16,"ia":1773619200,"of":[{"dy":0}],"rc":2,"sr":1773619200,"tp":0}"#, "2026-03-20", 2, )]); assert!(due_on(&store, "2026-03-25").is_empty()); }
#[test] fn template_ends_with_the_first_instance_when_the_bound_allows_one() { let today = day("2026-09-17"); let source = || TemplateSource { title: "once".to_string(), notes: None, tag_ids: Vec::new(), parent_project_ids: Vec::new(), area_ids: Vec::new(), action_group_ids: Vec::new(), alarm_time_offset: None, sort_index: 0, today_sort_index: 0, conflict_overrides: None, }; let daily = spec("daily"); for bound in [Bound::Times(1), Bound::Until(today)] { let made = template( &daily, daily.rule(today, today, bound), today, source(), 1.0, ) .expect("template"); assert_eq!( made.instance_creation_start_date, Some(day_timestamp(day("2026-09-18"))) ); assert_eq!(made.today_index_reference, None); } let made = template( &daily, daily.rule(today, today, Bound::Times(2)), today, source(), 1.0, ) .expect("template"); assert_eq!( made.today_index_reference, Some(day_timestamp(day("2026-09-18"))) ); }
#[test] fn template_points_at_the_following_occurrence() { let today = day("2026-09-17"); let source = || TemplateSource { title: "rp".to_string(), notes: None, tag_ids: Vec::new(), parent_project_ids: Vec::new(), area_ids: Vec::new(), action_group_ids: Vec::new(), alarm_time_offset: Some(32400), sort_index: -844, today_sort_index: -520, conflict_overrides: None, }; let weekly = spec("weekly:mon,thu"); let made = template( &weekly, weekly.rule(today, today, Bound::Never), today, source(), 1.0, ) .expect("template"); assert_eq!( made.instance_creation_start_date, Some(day_timestamp(day("2026-09-21"))) ); assert_eq!( made.today_index_reference, Some(day_timestamp(day("2026-09-21"))) ); assert_eq!(made.instance_creation_count, 1); assert_eq!(made.start_location, TaskStart::Someday); assert_eq!(made.alarm_time_offset, Some(32400));
let after = spec("after:2w"); let first = day("2027-12-31"); let made = template( &after, after.rule(first, today, Bound::Never), first, source(), 1.0, ) .expect("template"); assert_eq!( made.after_completion_reference_date, Some(day_timestamp(day("2027-12-17"))) ); assert_eq!( made.instance_creation_start_date, Some(day_timestamp(day("2028-01-01"))) ); }}