Skip to main content

things3_cloud/commands/
schedule.rs

1use crate::app::Cli;
2use crate::commands::Command;
3use crate::common::{colored, day_to_timestamp, parse_day, DIM, GREEN, ICONS};
4use crate::wire::task::{TaskPatch, TaskStart};
5use crate::wire::wire_object::{EntityType, WireObject};
6use anyhow::Result;
7use clap::Args;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Args)]
11#[command(about = "Set when and deadline")]
12pub struct ScheduleArgs {
13    /// Task UUID (or unique UUID prefix)
14    pub task_id: String,
15    #[arg(long, help = "When: anytime, today, evening, someday, or YYYY-MM-DD")]
16    pub when: Option<String>,
17    #[arg(long = "deadline", help = "Deadline date (YYYY-MM-DD)")]
18    pub deadline_date: Option<String>,
19    #[arg(long = "clear-deadline", help = "Clear deadline")]
20    pub clear_deadline: bool,
21}
22
23#[derive(Debug, Clone)]
24struct SchedulePlan {
25    task: crate::store::Task,
26    update: TaskPatch,
27    labels: Vec<String>,
28}
29
30fn build_schedule_plan(
31    args: &ScheduleArgs,
32    store: &crate::store::ThingsStore,
33    now: f64,
34    today_ts: i64,
35) -> std::result::Result<SchedulePlan, String> {
36    let (task_opt, err, _) = store.resolve_mark_identifier(&args.task_id);
37    let Some(task) = task_opt else {
38        return Err(err);
39    };
40
41    let mut update = TaskPatch::default();
42    let mut when_label: Option<String> = None;
43
44    if let Some(when_raw) = &args.when {
45        let when = when_raw.trim();
46        let when_l = when.to_lowercase();
47        if when_l == "anytime" {
48            update.start_location = Some(TaskStart::Anytime);
49            update.scheduled_date = Some(None);
50            update.today_index_reference = Some(None);
51            update.evening_bit = Some(0);
52            when_label = Some("anytime".to_string());
53        } else if when_l == "today" {
54            update.start_location = Some(TaskStart::Anytime);
55            update.scheduled_date = Some(Some(today_ts));
56            update.today_index_reference = Some(Some(today_ts));
57            update.evening_bit = Some(0);
58            when_label = Some("today".to_string());
59        } else if when_l == "evening" {
60            update.start_location = Some(TaskStart::Anytime);
61            update.scheduled_date = Some(Some(today_ts));
62            update.today_index_reference = Some(Some(today_ts));
63            update.evening_bit = Some(1);
64            when_label = Some("evening".to_string());
65        } else if when_l == "someday" {
66            update.start_location = Some(TaskStart::Someday);
67            update.scheduled_date = Some(None);
68            update.today_index_reference = Some(None);
69            update.evening_bit = Some(0);
70            when_label = Some("someday".to_string());
71        } else {
72            let when_day = match parse_day(Some(when), "--when") {
73                Ok(Some(day)) => day,
74                Ok(None) => {
75                    return Err(
76                        "--when requires anytime, someday, today, or YYYY-MM-DD".to_string()
77                    );
78                }
79                Err(e) => return Err(e),
80            };
81            let day_ts = day_to_timestamp(when_day);
82            if day_ts <= today_ts {
83                update.start_location = Some(TaskStart::Anytime);
84            } else {
85                update.start_location = Some(TaskStart::Someday);
86            }
87            update.scheduled_date = Some(Some(day_ts));
88            update.today_index_reference = Some(Some(day_ts));
89            update.evening_bit = Some(0);
90            when_label = Some(format!("when={when}"));
91        }
92    }
93
94    if let Some(deadline) = &args.deadline_date {
95        let day = match parse_day(Some(deadline), "--deadline") {
96            Ok(Some(day)) => day,
97            Ok(None) => return Err("--deadline requires YYYY-MM-DD".to_string()),
98            Err(e) => return Err(e),
99        };
100        update.deadline = Some(Some(day_to_timestamp(day) as f64));
101    }
102    if args.clear_deadline {
103        update.deadline = Some(None);
104    }
105
106    if update.is_empty() {
107        return Err("No schedule changes requested.".to_string());
108    }
109
110    update.modification_date = Some(now);
111
112    let mut labels = Vec::new();
113    if update.start_location.is_some() {
114        labels.push(when_label.unwrap_or_else(|| "when".to_string()));
115    }
116    if update.deadline.is_some() {
117        if update.deadline == Some(None) {
118            labels.push("deadline=none".to_string());
119        } else {
120            labels.push(format!(
121                "deadline={}",
122                args.deadline_date.clone().unwrap_or_default()
123            ));
124        }
125    }
126
127    Ok(SchedulePlan {
128        task,
129        update,
130        labels,
131    })
132}
133
134impl Command for ScheduleArgs {
135    fn run_with_ctx(
136        &self,
137        cli: &Cli,
138        out: &mut dyn std::io::Write,
139        ctx: &mut dyn crate::cmd_ctx::CmdCtx,
140    ) -> Result<()> {
141        let store = cli.load_store()?;
142        let plan =
143            match build_schedule_plan(self, &store, ctx.now_timestamp(), ctx.today_timestamp()) {
144                Ok(plan) => plan,
145                Err(err) => {
146                    eprintln!("{err}");
147                    return Ok(());
148                }
149            };
150
151        let mut changes = BTreeMap::new();
152        changes.insert(
153            plan.task.uuid.to_string(),
154            WireObject::update(
155                EntityType::from(plan.task.entity.clone()),
156                plan.update.clone(),
157            ),
158        );
159
160        if let Err(e) = ctx.commit_changes(changes, None) {
161            eprintln!("Failed to schedule item: {e}");
162            return Ok(());
163        }
164
165        writeln!(
166            out,
167            "{} {}  {} {}",
168            colored(&format!("{} Scheduled", ICONS.done), &[GREEN], cli.no_color),
169            plan.task.title,
170            colored(&plan.task.uuid, &[DIM], cli.no_color),
171            colored(
172                &format!("({})", plan.labels.join(", ")),
173                &[DIM],
174                cli.no_color
175            )
176        )?;
177
178        Ok(())
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::store::{fold_items, ThingsStore};
186    use crate::wire::task::{TaskProps, TaskStart, TaskStatus, TaskType};
187    use crate::wire::wire_object::WireItem;
188    use crate::wire::wire_object::{EntityType, WireObject};
189    use serde_json::json;
190
191    const NOW: f64 = 1_700_000_333.0;
192    const TASK_UUID: &str = "A7h5eCi24RvAWKC3Hv3muf";
193    const TODAY: i64 = 1_700_000_000;
194
195    fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
196        let mut item: WireItem = BTreeMap::new();
197        for (uuid, obj) in entries {
198            item.insert(uuid, obj);
199        }
200        ThingsStore::from_raw_state(&fold_items([item]))
201    }
202
203    fn task(uuid: &str, title: &str) -> (String, WireObject) {
204        (
205            uuid.to_string(),
206            WireObject::create(
207                EntityType::Task6,
208                TaskProps {
209                    title: title.to_string(),
210                    item_type: TaskType::Todo,
211                    status: TaskStatus::Incomplete,
212                    start_location: TaskStart::Inbox,
213                    sort_index: 0,
214                    creation_date: Some(1.0),
215                    modification_date: Some(1.0),
216                    ..Default::default()
217                },
218            ),
219        )
220    }
221
222    #[test]
223    fn schedule_when_variants_payloads() {
224        let store = build_store(vec![task(TASK_UUID, "Schedule me")]);
225        let future_ts = day_to_timestamp(
226            parse_day(Some("2099-05-10"), "--when")
227                .expect("parse")
228                .expect("day"),
229        );
230        let cases = [
231            (
232                "today",
233                json!({"st":1,"sr":TODAY,"tir":TODAY,"sb":0,"md":NOW}),
234            ),
235            (
236                "someday",
237                json!({"st":2,"sr":null,"tir":null,"sb":0,"md":NOW}),
238            ),
239            (
240                "anytime",
241                json!({"st":1,"sr":null,"tir":null,"sb":0,"md":NOW}),
242            ),
243            (
244                "evening",
245                json!({"st":1,"sr":TODAY,"tir":TODAY,"sb":1,"md":NOW}),
246            ),
247            (
248                "2099-05-10",
249                json!({"st":2,"sr":future_ts,"tir":future_ts,"sb":0,"md":NOW}),
250            ),
251        ];
252
253        for (when, expected) in cases {
254            let plan = build_schedule_plan(
255                &ScheduleArgs {
256                    task_id: TASK_UUID.to_string(),
257                    when: Some(when.to_string()),
258                    deadline_date: None,
259                    clear_deadline: false,
260                },
261                &store,
262                NOW,
263                TODAY,
264            )
265            .expect("schedule plan");
266            assert_eq!(
267                serde_json::to_value(plan.update).expect("to value"),
268                expected
269            );
270        }
271    }
272
273    #[test]
274    fn schedule_deadline_and_clear_payloads() {
275        let store = build_store(vec![task(TASK_UUID, "Schedule me")]);
276        let deadline_ts = day_to_timestamp(
277            parse_day(Some("2034-02-01"), "--deadline")
278                .expect("parse")
279                .expect("day"),
280        );
281
282        let deadline = build_schedule_plan(
283            &ScheduleArgs {
284                task_id: TASK_UUID.to_string(),
285                when: None,
286                deadline_date: Some("2034-02-01".to_string()),
287                clear_deadline: false,
288            },
289            &store,
290            NOW,
291            TODAY,
292        )
293        .expect("deadline plan");
294        assert_eq!(
295            serde_json::to_value(deadline.update).expect("to value"),
296            json!({"dd": deadline_ts as f64, "md": NOW})
297        );
298
299        let clear = build_schedule_plan(
300            &ScheduleArgs {
301                task_id: TASK_UUID.to_string(),
302                when: None,
303                deadline_date: None,
304                clear_deadline: true,
305            },
306            &store,
307            NOW,
308            TODAY,
309        )
310        .expect("clear plan");
311        assert_eq!(
312            serde_json::to_value(clear.update).expect("to value"),
313            json!({"dd": null, "md": NOW})
314        );
315    }
316
317    #[test]
318    fn schedule_rejections() {
319        let store = build_store(vec![task(TASK_UUID, "A")]);
320        let no_changes = build_schedule_plan(
321            &ScheduleArgs {
322                task_id: TASK_UUID.to_string(),
323                when: None,
324                deadline_date: None,
325                clear_deadline: false,
326            },
327            &store,
328            NOW,
329            TODAY,
330        )
331        .expect_err("no changes");
332        assert_eq!(no_changes, "No schedule changes requested.");
333
334        let invalid_when = build_schedule_plan(
335            &ScheduleArgs {
336                task_id: TASK_UUID.to_string(),
337                when: Some("2024-02-31".to_string()),
338                deadline_date: None,
339                clear_deadline: false,
340            },
341            &store,
342            NOW,
343            TODAY,
344        )
345        .expect_err("invalid when");
346        assert_eq!(
347            invalid_when,
348            "Invalid --when date: 2024-02-31 (expected YYYY-MM-DD)"
349        );
350    }
351}