1use crate::app::Cli;
2use crate::commands::Command;
3use crate::common::{DIM, GREEN, ICONS, colored, day_to_timestamp, parse_day};
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 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::{ThingsStore, fold_items};
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}