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