Skip to main content

things3_cloud/commands/
schedule.rs

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