1use std::fmt;
8use std::str::FromStr;
9
10use jiff::civil::{Date, DateTime, Time, Weekday};
11use jiff::tz::TimeZone;
12use jiff::{Timestamp, ToSpan as _};
13use serde::{Deserialize, Serialize};
14
15use crate::id::{ChannelId, ConversationKey};
16use crate::knowledge::Requester;
17use crate::memory::MemoryScope;
18use crate::{BoxError, async_trait};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum TaskKind {
24 Todo,
26 Reminder,
28 Routine,
30}
31
32impl TaskKind {
33 pub fn as_str(self) -> &'static str {
35 match self {
36 Self::Todo => "todo",
37 Self::Reminder => "reminder",
38 Self::Routine => "routine",
39 }
40 }
41}
42
43impl FromStr for TaskKind {
44 type Err = String;
45
46 fn from_str(s: &str) -> Result<Self, Self::Err> {
47 match s.trim() {
48 "todo" => Ok(Self::Todo),
49 "reminder" => Ok(Self::Reminder),
50 "routine" => Ok(Self::Routine),
51 other => Err(format!(
52 "unknown kind `{other}` (todo, reminder or routine)"
53 )),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum TaskStatus {
62 Open,
64 Paused,
66 Done,
68 Cancelled,
70}
71
72impl TaskStatus {
73 pub fn as_str(self) -> &'static str {
75 match self {
76 Self::Open => "open",
77 Self::Paused => "paused",
78 Self::Done => "done",
79 Self::Cancelled => "cancelled",
80 }
81 }
82
83 pub fn is_active(self) -> bool {
85 matches!(self, Self::Open | Self::Paused)
86 }
87}
88
89impl FromStr for TaskStatus {
90 type Err = String;
91
92 fn from_str(s: &str) -> Result<Self, Self::Err> {
93 match s.trim() {
94 "open" => Ok(Self::Open),
95 "paused" => Ok(Self::Paused),
96 "done" => Ok(Self::Done),
97 "cancelled" | "canceled" => Ok(Self::Cancelled),
98 other => Err(format!(
99 "unknown status `{other}` (open, paused, done or cancelled)"
100 )),
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Repeat {
109 Daily(Time),
111 Weekdays(Time),
113 Weekly(Vec<Weekday>, Time),
115 Monthly(i8, Time),
117 EveryHours(i64),
119}
120
121const DAYS: [(&str, Weekday); 7] = [
122 ("mon", Weekday::Monday),
123 ("tue", Weekday::Tuesday),
124 ("wed", Weekday::Wednesday),
125 ("thu", Weekday::Thursday),
126 ("fri", Weekday::Friday),
127 ("sat", Weekday::Saturday),
128 ("sun", Weekday::Sunday),
129];
130
131fn parse_clock(s: &str) -> Result<Time, String> {
132 let (h, m) = s
133 .split_once(':')
134 .ok_or_else(|| format!("`{s}` must be a time such as 08:00"))?;
135 let h: i8 = h.parse().map_err(|_| format!("`{s}` is not a time"))?;
136 let m: i8 = m.parse().map_err(|_| format!("`{s}` is not a time"))?;
137 Time::new(h, m, 0, 0).map_err(|_| format!("`{s}` is not a time"))
138}
139
140fn day_name(day: Weekday) -> &'static str {
141 DAYS.iter()
142 .find(|(_, d)| *d == day)
143 .map_or("mon", |(name, _)| name)
144}
145
146impl FromStr for Repeat {
147 type Err = String;
148
149 fn from_str(s: &str) -> Result<Self, Self::Err> {
150 let lower = s.trim().to_ascii_lowercase();
151 let words: Vec<&str> = lower.split_whitespace().collect();
152 match words.as_slice() {
153 ["daily", at] => Ok(Self::Daily(parse_clock(at)?)),
154 ["weekdays", at] => Ok(Self::Weekdays(parse_clock(at)?)),
155 ["weekly", days, at] => {
156 let mut list = Vec::new();
157 for name in days.split(',') {
158 let day = DAYS
159 .iter()
160 .find(|(n, _)| name.starts_with(n))
161 .map(|(_, d)| *d)
162 .ok_or_else(|| format!("`{name}` is not a day (mon … sun)"))?;
163 if !list.contains(&day) {
164 list.push(day);
165 }
166 }
167 list.sort_by_key(|d| d.to_monday_one_offset());
168 Ok(Self::Weekly(list, parse_clock(at)?))
169 }
170 ["monthly", day, at] => {
171 let day: i8 = day
172 .parse()
173 .ok()
174 .filter(|d| (1..=31).contains(d))
175 .ok_or_else(|| format!("`{day}` is not a day of the month (1 to 31)"))?;
176 Ok(Self::Monthly(day, parse_clock(at)?))
177 }
178 ["every", hours] => {
179 let n: i64 = hours
180 .strip_suffix('h')
181 .and_then(|n| n.parse().ok())
182 .filter(|n| (1..=168).contains(n))
183 .ok_or_else(|| format!("`every {hours}` must be 1h to 168h"))?;
184 Ok(Self::EveryHours(n))
185 }
186 _ => Err(format!(
187 "`{s}` is not a repeat rule: use `daily 08:00`, `weekdays 08:00`, \
188 `weekly mon,fri 09:00`, `monthly 1 09:00` or `every 3h`"
189 )),
190 }
191 }
192}
193
194impl fmt::Display for Repeat {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 let clock = |t: &Time| format!("{:02}:{:02}", t.hour(), t.minute());
197 match self {
198 Self::Daily(t) => write!(f, "daily {}", clock(t)),
199 Self::Weekdays(t) => write!(f, "weekdays {}", clock(t)),
200 Self::Weekly(days, t) => {
201 let names: Vec<&str> = days.iter().map(|d| day_name(*d)).collect();
202 write!(f, "weekly {} {}", names.join(","), clock(t))
203 }
204 Self::Monthly(day, t) => write!(f, "monthly {day} {}", clock(t)),
205 Self::EveryHours(n) => write!(f, "every {n}h"),
206 }
207 }
208}
209
210impl Repeat {
211 fn matches(&self, date: Date) -> bool {
212 match self {
213 Self::Weekdays(_) => !matches!(date.weekday(), Weekday::Saturday | Weekday::Sunday),
214 Self::Weekly(days, _) => days.contains(&date.weekday()),
215 Self::Monthly(day, _) => date.day() == (*day).min(date.days_in_month()),
216 Self::Daily(_) | Self::EveryHours(_) => true,
217 }
218 }
219
220 pub fn next_after(&self, after: Timestamp, tz: &TimeZone) -> Option<Timestamp> {
223 let at = match self {
224 Self::EveryHours(n) => return after.checked_add(n.hours()).ok(),
225 Self::Daily(t) | Self::Weekdays(t) | Self::Weekly(_, t) | Self::Monthly(_, t) => *t,
226 };
227 let mut date = after.to_zoned(tz.clone()).date();
228 for _ in 0..400 {
230 if self.matches(date) {
231 let when = date.to_datetime(at).to_zoned(tz.clone()).ok()?.timestamp();
232 if when > after {
233 return Some(when);
234 }
235 }
236 date = date.tomorrow().ok()?;
237 }
238 None
239 }
240}
241
242pub fn parse_time(text: &str, tz: &TimeZone) -> Result<Timestamp, String> {
245 let text = text.trim();
246 if let Ok(ts) = text.parse::<Timestamp>() {
247 return Ok(ts);
248 }
249 let local = text.replacen(' ', "T", 1);
250 let datetime = if local.contains('T') {
252 local.parse::<DateTime>()
253 } else {
254 local
255 .parse::<Date>()
256 .map(|d| d.to_datetime(Time::constant(9, 0, 0, 0)))
257 };
258 datetime
259 .ok()
260 .and_then(|dt| dt.to_zoned(tz.clone()).ok())
261 .map(|z| z.timestamp())
262 .ok_or_else(|| {
263 format!("`{text}` is not a time: use `YYYY-MM-DD HH:MM` (local) or RFC 3339")
264 })
265}
266
267pub fn format_time(ts: Timestamp, tz: &TimeZone) -> String {
269 ts.to_zoned(tz.clone())
270 .strftime("%Y-%m-%dT%H:%M:%S%:z")
271 .to_string()
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct Task {
277 pub id: String,
279 pub kind: TaskKind,
281 pub status: TaskStatus,
283 pub title: String,
285 pub notes: String,
287 pub scope: MemoryScope,
289 pub due: Option<Timestamp>,
291 pub remind_at: Option<Timestamp>,
293 pub repeat: Option<Repeat>,
295 pub deliver_to: Option<ChannelId>,
298 pub origin: Option<ConversationKey>,
300 pub author: String,
302 pub created: Timestamp,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct NewTask {
309 pub kind: TaskKind,
311 pub title: String,
313 pub notes: String,
315 pub scope: MemoryScope,
317 pub due: Option<Timestamp>,
319 pub remind_at: Option<Timestamp>,
321 pub repeat: Option<Repeat>,
323 pub deliver_to: Option<ChannelId>,
325 pub paused: bool,
327}
328
329impl NewTask {
330 pub fn todo(title: impl Into<String>) -> Self {
332 Self {
333 kind: TaskKind::Todo,
334 title: title.into(),
335 notes: String::new(),
336 scope: MemoryScope::User,
337 due: None,
338 remind_at: None,
339 repeat: None,
340 deliver_to: None,
341 paused: false,
342 }
343 }
344}
345
346#[derive(Debug, Clone, Default, PartialEq, Eq)]
348pub struct TaskChange {
349 pub title: Option<String>,
351 pub notes: Option<String>,
353 pub status: Option<TaskStatus>,
355 pub due: Option<Option<Timestamp>>,
357 pub remind_at: Option<Option<Timestamp>>,
359 pub repeat: Option<Option<Repeat>>,
361}
362
363#[derive(Debug, thiserror::Error)]
365#[non_exhaustive]
366pub enum TaskError {
367 #[error("no task `{0}` is visible here")]
369 NotFound(String),
370 #[error("not allowed: {0}")]
372 Forbidden(String),
373 #[error("{0}")]
375 Invalid(String),
376 #[error("task storage: {0}")]
378 Backend(#[source] BoxError),
379}
380
381#[async_trait]
383pub trait TaskStore: Send + Sync {
384 async fn add(&self, who: &Requester, task: NewTask) -> Result<Task, TaskError>;
386
387 async fn list(&self, who: &Requester) -> Result<Vec<Task>, TaskError>;
390
391 async fn update(
393 &self,
394 who: &Requester,
395 id: &str,
396 change: TaskChange,
397 ) -> Result<Task, TaskError>;
398
399 async fn delete(&self, who: &Requester, id: &str) -> Result<Task, TaskError>;
401
402 async fn scheduled(&self) -> Result<Vec<Task>, TaskError>;
404
405 async fn after_fire(&self, id: &str, change: TaskChange) -> Result<Task, TaskError>;
407}
408
409pub fn sort_tasks(tasks: &mut [Task]) {
412 tasks.sort_by(|a, b| {
413 let when = |t: &Task| match (t.remind_at, t.due) {
414 (Some(r), Some(d)) => Some(r.min(d)),
415 (r, d) => r.or(d),
416 };
417 let key = |t: &Task| (!t.status.is_active(), when(t).is_none(), when(t), t.created);
418 key(a).cmp(&key(b)).then_with(|| a.id.cmp(&b.id))
419 });
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn hcm() -> TimeZone {
427 TimeZone::get("Asia/Ho_Chi_Minh").unwrap()
428 }
429
430 fn at(text: &str) -> Timestamp {
431 text.parse().unwrap()
432 }
433
434 #[test]
435 fn repeat_rules_round_trip() {
436 for rule in [
437 "daily 08:00",
438 "weekdays 07:30",
439 "weekly mon,fri 09:00",
440 "monthly 31 18:05",
441 "every 3h",
442 ] {
443 assert_eq!(rule.parse::<Repeat>().unwrap().to_string(), rule);
444 }
445 assert_eq!(
446 "Weekly FRI,monday,fri 9:00"
447 .parse::<Repeat>()
448 .unwrap()
449 .to_string(),
450 "weekly mon,fri 09:00"
451 );
452 for bad in [
453 "daily",
454 "daily 25:00",
455 "every 0h",
456 "every 2d",
457 "monthly 32 08:00",
458 "x",
459 ] {
460 assert!(bad.parse::<Repeat>().is_err(), "{bad}");
461 }
462 }
463
464 #[test]
465 fn next_firings() {
466 let tz = hcm();
467 let daily: Repeat = "daily 08:00".parse().unwrap();
468 assert_eq!(
470 daily.next_after(at("2026-10-13T00:00:00Z"), &tz),
471 Some(at("2026-10-13T01:00:00Z"))
472 );
473 assert_eq!(
474 daily.next_after(at("2026-10-13T01:00:00Z"), &tz),
475 Some(at("2026-10-14T01:00:00Z"))
476 );
477 let weekdays: Repeat = "weekdays 09:00".parse().unwrap();
479 assert_eq!(
480 weekdays.next_after(at("2026-10-16T03:00:00Z"), &tz),
481 Some(at("2026-10-19T02:00:00Z"))
482 );
483 let monthly: Repeat = "monthly 31 08:00".parse().unwrap();
485 assert_eq!(
486 monthly.next_after(at("2026-11-01T00:00:00Z"), &tz),
487 Some(at("2026-11-30T01:00:00Z"))
488 );
489 let every: Repeat = "every 3h".parse().unwrap();
490 assert_eq!(
491 every.next_after(at("2026-10-13T00:00:00Z"), &tz),
492 Some(at("2026-10-13T03:00:00Z"))
493 );
494 }
495
496 #[test]
497 fn dst_gap_moves_forward() {
498 let tz = TimeZone::get("Europe/Berlin").unwrap();
499 let daily: Repeat = "daily 02:30".parse().unwrap();
501 assert_eq!(
502 daily.next_after(at("2027-03-27T12:00:00Z"), &tz),
503 Some(at("2027-03-28T01:30:00Z"))
504 );
505 }
506
507 #[test]
508 fn parses_local_and_absolute_times() {
509 let tz = hcm();
510 let want = at("2026-10-14T01:00:00Z");
511 assert_eq!(parse_time("2026-10-14T08:00", &tz), Ok(want));
512 assert_eq!(parse_time("2026-10-14 08:00", &tz), Ok(want));
513 assert_eq!(parse_time("2026-10-14T08:00:00+07:00", &tz), Ok(want));
514 assert_eq!(
515 parse_time("2026-10-14", &tz),
516 Ok(at("2026-10-14T02:00:00Z"))
517 );
518 assert!(parse_time("tomorrow", &tz).is_err());
519 assert_eq!(format_time(want, &tz), "2026-10-14T08:00:00+07:00");
520 }
521}