Skip to main content

okbot_core/
task.rs

1//! The task port: to-dos, reminders and routines (design §5.6).
2//!
3//! A task is one file in the knowledge bundle; this module holds the model, the repeat rules and
4//! the [`TaskStore`] trait. Times are [`Timestamp`]s; rules and local input are read in the bot's
5//! time zone.
6
7use 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/// What a task does when it fires.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum TaskKind {
24    /// Something to do; a reminder, if any, does not close it.
25    Todo,
26    /// A message at a time; one-shot reminders are done once sent.
27    Reminder,
28    /// An agent turn at a time, with the notes as the prompt (design §5.7).
29    Routine,
30}
31
32impl TaskKind {
33    /// The name used in files and tools.
34    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/// Where a task is in its life.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum TaskStatus {
62    /// Waiting to be done or to fire.
63    Open,
64    /// Kept but not firing (routines waiting for confirmation, paused by the user).
65    Paused,
66    /// Finished.
67    Done,
68    /// Dropped.
69    Cancelled,
70}
71
72impl TaskStatus {
73    /// The name used in files and tools.
74    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    /// Open or paused.
84    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/// When a task fires again, written `daily 08:00`, `weekdays 08:00`, `weekly mon,fri 09:00`,
106/// `monthly 1 09:00` or `every 3h`.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Repeat {
109    /// Every day at a local time.
110    Daily(Time),
111    /// Monday to Friday at a local time.
112    Weekdays(Time),
113    /// On some days of the week (Monday first, no duplicates) at a local time.
114    Weekly(Vec<Weekday>, Time),
115    /// On a day of the month (the last day in shorter months) at a local time.
116    Monthly(i8, Time),
117    /// Every few hours after the previous firing (1 to 168).
118    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    /// The first firing strictly after `after`, in `tz` (local times that do not exist on a DST
221    /// change move forward).
222    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        // Monthly rules need at most a month; 400 days covers every rule with margin.
229        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
242/// Reads a time written by a user or an agent: an RFC 3339 instant (`2026-10-14T08:00:00+07:00`),
243/// a local date-time in `tz` (`2026-10-14T08:00`, `2026-10-14 08:00`) or a local date (09:00).
244pub 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    // A date alone means 09:00 (jiff would read it as midnight).
251    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
267/// Formats `ts` as an RFC 3339 instant with the offset of `tz` (`2026-10-14T08:00:00+07:00`).
268pub 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/// A stored task.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct Task {
277    /// Id in the knowledge bundle (`tasks/people/owner/t-7k2m9q`).
278    pub id: String,
279    /// What it does.
280    pub kind: TaskKind,
281    /// Where it is in its life.
282    pub status: TaskStatus,
283    /// Short title.
284    pub title: String,
285    /// Free notes; the prompt of a routine.
286    pub notes: String,
287    /// Personal (`User`) or for the room (`Room`).
288    pub scope: MemoryScope,
289    /// When it should be done.
290    pub due: Option<Timestamp>,
291    /// The next firing.
292    pub remind_at: Option<Timestamp>,
293    /// How it repeats.
294    pub repeat: Option<Repeat>,
295    /// Where it fires: `None` for the conversation it was created in, or a channel (the owner's
296    /// direct chat there).
297    pub deliver_to: Option<ChannelId>,
298    /// The conversation it was created in.
299    pub origin: Option<ConversationKey>,
300    /// `platform:instance/user` of the author.
301    pub author: String,
302    /// When it was created.
303    pub created: Timestamp,
304}
305
306/// A task to create.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct NewTask {
309    /// What it does.
310    pub kind: TaskKind,
311    /// Short title.
312    pub title: String,
313    /// Free notes; the prompt of a routine.
314    pub notes: String,
315    /// Personal (`User`) or for the room (`Room`); `Global` is refused.
316    pub scope: MemoryScope,
317    /// When it should be done.
318    pub due: Option<Timestamp>,
319    /// The first firing.
320    pub remind_at: Option<Timestamp>,
321    /// How it repeats.
322    pub repeat: Option<Repeat>,
323    /// Where it fires (`None`: here).
324    pub deliver_to: Option<ChannelId>,
325    /// Created paused (routines that need confirmation).
326    pub paused: bool,
327}
328
329impl NewTask {
330    /// A personal to-do with only a title.
331    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/// Changes to a task; `None` leaves a field as it is.
347#[derive(Debug, Clone, Default, PartialEq, Eq)]
348pub struct TaskChange {
349    /// New title.
350    pub title: Option<String>,
351    /// New notes.
352    pub notes: Option<String>,
353    /// New status.
354    pub status: Option<TaskStatus>,
355    /// New due time (`Some(None)` clears it).
356    pub due: Option<Option<Timestamp>>,
357    /// New next firing (`Some(None)` clears it).
358    pub remind_at: Option<Option<Timestamp>>,
359    /// New repeat rule (`Some(None)` clears it).
360    pub repeat: Option<Option<Repeat>>,
361}
362
363/// A task operation failed.
364#[derive(Debug, thiserror::Error)]
365#[non_exhaustive]
366pub enum TaskError {
367    /// No such task in the requester's scope.
368    #[error("no task `{0}` is visible here")]
369    NotFound(String),
370    /// The requester may not do this.
371    #[error("not allowed: {0}")]
372    Forbidden(String),
373    /// The request is not acceptable.
374    #[error("{0}")]
375    Invalid(String),
376    /// Storage failed.
377    #[error("task storage: {0}")]
378    Backend(#[source] BoxError),
379}
380
381/// Creates, lists and changes tasks, always within the requester's visibility.
382#[async_trait]
383pub trait TaskStore: Send + Sync {
384    /// Creates a task.
385    async fn add(&self, who: &Requester, task: NewTask) -> Result<Task, TaskError>;
386
387    /// Tasks visible to `who` (theirs and their room's), every status, in display order: active
388    /// first, then by next firing or due time, then by creation.
389    async fn list(&self, who: &Requester) -> Result<Vec<Task>, TaskError>;
390
391    /// Changes a visible task.
392    async fn update(
393        &self,
394        who: &Requester,
395        id: &str,
396        change: TaskChange,
397    ) -> Result<Task, TaskError>;
398
399    /// Deletes a visible task.
400    async fn delete(&self, who: &Requester, id: &str) -> Result<Task, TaskError>;
401
402    /// Every open or paused task with a next firing, of every owner (the scheduler).
403    async fn scheduled(&self) -> Result<Vec<Task>, TaskError>;
404
405    /// Changes any task by id after it fired (the scheduler; no visibility check).
406    async fn after_fire(&self, id: &str, change: TaskChange) -> Result<Task, TaskError>;
407}
408
409/// Sorts tasks for display: active first, then by the earliest of next firing and due time, then
410/// by creation.
411pub 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        // 2026-10-13 07:00 local → same day 08:00; 08:00 exactly → next day.
469        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        // Friday 2026-10-16 after 09:00 → Monday 2026-10-19.
478        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        // The 31st in a 30-day month is the 30th.
484        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        // 2027-03-28 02:30 does not exist in Berlin; the firing is at 03:30 local (01:30Z).
500        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}