···94949595 let name = Span::from(task.name.clone()).style(Style::new().fg(color.into()));
9696 let group = Span::from(task.group.name.clone()).style(Style::new().fg(color.into()));
9797- let due_priority = task
9898- .due()
9999- .map_or_else(|| Span::from(task.priority.to_string()), Span::from)
100100- .style(Style::new().fg(color.into()));
9797+ let due_priority = Span::from(if task.due.has_date() {
9898+ task.due.to_string()
9999+ } else {
100100+ task.priority.to_string()
101101+ })
102102+ .style(Style::new().fg(color.into()));
101103102104 Self {
103105 name,
+4
src/tui/signal.rs
···7474 /// Only works with the inspector
7575 EditPriority,
76767777+ /// Edit the `DueDate` of a `Task`
7878+ /// Only works with the inspector
7979+ EditDue,
8080+7781 /// Internal Signal that tells the app to resume interpreting keys
7882 ExitRawText,
7983
+237
src/types/due.rs
···11+use crate::types::frontmatter;
22+use chrono::Datelike;
33+use std::fmt::Display;
44+55+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
66+pub struct Due(Option<dto::DateTime>);
77+88+impl Due {
99+ pub const fn has_date(&self) -> bool {
1010+ self.0.is_some()
1111+ }
1212+}
1313+1414+impl From<Option<dto::DateTime>> for Due {
1515+ fn from(value: Option<dto::DateTime>) -> Self {
1616+ Self(value)
1717+ }
1818+}
1919+2020+impl From<Due> for Option<dto::DateTime> {
2121+ fn from(value: Due) -> Self {
2222+ value.0
2323+ }
2424+}
2525+2626+impl Display for Due {
2727+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2828+ let str = self.0.map_or_else(
2929+ || "None".to_string(),
3030+ |d| d.format(frontmatter::DATE_FMT_STR).to_string(),
3131+ );
3232+ write!(f, "{str}")
3333+ }
3434+}
3535+3636+impl TryFrom<&str> for Due {
3737+ type Error = color_eyre::Report;
3838+ fn try_from(value: &str) -> Result<Self, Self::Error> {
3939+ parse_due_date(value)
4040+ .map(Into::into)
4141+ .map_err(|e| color_eyre::eyre::eyre!(e))
4242+ }
4343+}
4444+4545+//NOTE: everything after this was written by claude because I am
4646+// way too fucking lazy to do all this due date parsing bs, I did audit it though.
4747+4848+const fn naive_date_to_dt(d: chrono::NaiveDate) -> chrono::NaiveDateTime {
4949+ d.and_hms_opt(23, 59, 59).unwrap()
5050+}
5151+5252+#[expect(clippy::cast_possible_truncation)]
5353+fn parse_relative_offset(lower: &str, today: chrono::NaiveDate) -> Option<chrono::NaiveDate> {
5454+ let lower = lower.strip_prefix("in ")?.trim();
5555+ let mut parts = lower.splitn(2, ' ');
5656+ let n: i64 = parts.next()?.parse().ok()?;
5757+ let unit = parts.next()?.trim_end_matches('s');
5858+ match unit {
5959+ "day" => Some(today + chrono::Duration::days(n)),
6060+ "week" => Some(today + chrono::Duration::weeks(n)),
6161+ "month" => {
6262+ let total_months = today.month0().cast_signed() + n as i32;
6363+ let year = today.year() + total_months / 12;
6464+ let month = (total_months % 12).cast_unsigned() + 1;
6565+ chrono::NaiveDate::from_ymd_opt(year, month, today.day()).or_else(|| {
6666+ chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).and_then(|d| d.pred_opt())
6767+ })
6868+ }
6969+ "year" => {
7070+ chrono::NaiveDate::from_ymd_opt(today.year() + n as i32, today.month(), today.day())
7171+ }
7272+ _ => None,
7373+ }
7474+}
7575+7676+fn parse_weekday(lower: &str, today: chrono::NaiveDate) -> Option<chrono::NaiveDate> {
7777+ use chrono::Weekday::{Fri, Mon, Sat, Sun, Thu, Tue, Wed};
7878+ let (force_next, word) = lower
7979+ .strip_prefix("next ")
8080+ .map_or((false, lower), |rest| (true, rest.trim()));
8181+ let target = match word {
8282+ "monday" | "mon" => Mon,
8383+ "tuesday" | "tue" | "tues" => Tue,
8484+ "wednesday" | "wed" => Wed,
8585+ "thursday" | "thu" | "thur" | "thurs" => Thu,
8686+ "friday" | "fri" => Fri,
8787+ "saturday" | "sat" => Sat,
8888+ "sunday" | "sun" => Sun,
8989+ _ => return None,
9090+ };
9191+ let mut days_ahead = i64::from(target.num_days_from_monday())
9292+ - i64::from(today.weekday().num_days_from_monday());
9393+ if days_ahead <= 0 || force_next {
9494+ days_ahead += 7;
9595+ }
9696+ Some(today + chrono::Duration::days(days_ahead))
9797+}
9898+9999+#[expect(clippy::too_many_lines)]
100100+fn parse_due_date(s: &str) -> Result<Option<dto::DateTime>, String> {
101101+ use chrono::{Datelike, Local, NaiveDate, NaiveDateTime};
102102+103103+ let s = s.trim();
104104+105105+ // 1. Empty / explicit "no due date" sentinels
106106+ if s.is_empty()
107107+ || matches!(
108108+ s.to_lowercase().as_str(),
109109+ "none" | "never" | "n/a" | "na" | "-" | "--" | "null" | "nil" | "no" | "no due date"
110110+ )
111111+ {
112112+ return Ok(None);
113113+ }
114114+115115+ let lower = s.to_lowercase();
116116+ let today = Local::now().date_naive();
117117+118118+ // 2. Relative human words
119119+ let relative: Option<NaiveDate> = match lower.as_str() {
120120+ "tomorrow" | "tmrw" | "tmr" => Some(today + chrono::Duration::days(1)),
121121+ "yesterday" => Some(today - chrono::Duration::days(1)),
122122+ "today" | "now" | "eod" | "end of day" => Some(today),
123123+ "eow" | "end of week" => {
124124+ let days = (7 - today.weekday().num_days_from_sunday()) % 7;
125125+ Some(today + chrono::Duration::days(i64::from(days)))
126126+ }
127127+ "eom" | "end of month" => {
128128+ let next = if today.month() == 12 {
129129+ NaiveDate::from_ymd_opt(today.year() + 1, 1, 1)
130130+ } else {
131131+ NaiveDate::from_ymd_opt(today.year(), today.month() + 1, 1)
132132+ };
133133+ next.and_then(|d| d.pred_opt())
134134+ }
135135+ "eoy" | "end of year" => NaiveDate::from_ymd_opt(today.year(), 12, 31),
136136+ _ => None,
137137+ };
138138+ if let Some(d) = relative {
139139+ return Ok(Some(naive_date_to_dt(d)));
140140+ }
141141+142142+ // 3. "in N days/weeks/months/years"
143143+ if let Some(d) = parse_relative_offset(&lower, today) {
144144+ return Ok(Some(naive_date_to_dt(d)));
145145+ }
146146+147147+ // 4. Named weekdays
148148+ if let Some(d) = parse_weekday(&lower, today) {
149149+ return Ok(Some(naive_date_to_dt(d)));
150150+ }
151151+152152+ let s_noz = s.trim_end_matches('Z');
153153+154154+ // 5. Datetime formats
155155+ let datetime_fmts: &[&str] = &[
156156+ "%Y-%m-%dT%H:%M:%S",
157157+ "%Y-%m-%dT%H:%M",
158158+ "%Y-%m-%d %H:%M:%S",
159159+ "%Y-%m-%d %H:%M",
160160+ "%d/%m/%Y %H:%M:%S",
161161+ "%d/%m/%Y %H:%M",
162162+ "%m/%d/%Y %H:%M:%S",
163163+ "%m/%d/%Y %H:%M",
164164+ "%d-%m-%Y %H:%M",
165165+ "%m-%d-%Y %H:%M",
166166+ "%d %b %Y %H:%M",
167167+ "%d %B %Y %H:%M",
168168+ "%b %d %Y %H:%M",
169169+ "%B %d %Y %H:%M",
170170+ "%b %d, %Y %H:%M",
171171+ "%B %d, %Y %H:%M",
172172+ ];
173173+ for fmt in datetime_fmts {
174174+ if let Ok(dt) = NaiveDateTime::parse_from_str(s_noz, fmt) {
175175+ return Ok(Some(dt));
176176+ }
177177+ }
178178+179179+ // 6. Date-only formats
180180+ let date_fmts: &[&str] = &[
181181+ "%Y-%m-%d",
182182+ "%Y/%m/%d",
183183+ "%Y.%m.%d",
184184+ "%d/%m/%Y",
185185+ "%m/%d/%Y",
186186+ "%d-%m-%Y",
187187+ "%m-%d-%Y",
188188+ "%d.%m.%Y",
189189+ "%d %b %Y",
190190+ "%d %B %Y",
191191+ "%b %d %Y",
192192+ "%B %d %Y",
193193+ "%b %d, %Y",
194194+ "%B %d, %Y",
195195+ "%b %Y",
196196+ "%B %Y",
197197+ "%Y%m%d",
198198+ ];
199199+ for fmt in date_fmts {
200200+ if let Ok(d) = NaiveDate::parse_from_str(s_noz, fmt) {
201201+ return Ok(Some(naive_date_to_dt(d)));
202202+ }
203203+ }
204204+205205+ // 6b. Yearless shorthand e.g. "4/13" or "4-13" — fill in current year
206206+ let yearless_fmts: &[&str] = &["%m/%d", "%m-%d"];
207207+ for fmt in yearless_fmts {
208208+ if let Ok(d) = NaiveDate::parse_from_str(
209209+ &format!("{}/{}", s_noz, today.year()),
210210+ &format!("{}/{}", fmt, "%Y"),
211211+ ) {
212212+ return Ok(Some(naive_date_to_dt(d)));
213213+ }
214214+ }
215215+216216+ // 7. Unix timestamp (seconds or milliseconds)
217217+ if let Ok(n) = s.parse::<i64>() {
218218+ let secs = if n > 10_000_000_000 { n / 1000 } else { n };
219219+ if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) {
220220+ return Ok(Some(dt.naive_utc()));
221221+ }
222222+ }
223223+224224+ // 8. Strip noise and retry date-only
225225+ let stripped: String = s
226226+ .chars()
227227+ .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == ' ')
228228+ .collect();
229229+ let stripped = stripped.trim();
230230+ for fmt in date_fmts {
231231+ if let Ok(d) = NaiveDate::parse_from_str(stripped, fmt) {
232232+ return Ok(Some(naive_date_to_dt(d)));
233233+ }
234234+ }
235235+236236+ Err(format!("could not parse {s:?} as a due date"))
237237+}
+3
src/types/mod.rs
···1111pub use zettel::Zettel;
1212pub use zettel::ZettelId;
13131414+mod due;
1515+pub use due::Due;
1616+1417mod group;
1518pub use group::Group;
1619