My personal-knowledge-system, with deeply integrated task tracking and long term goal planning capabilities.
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: due dates!

+390 -25
+2 -1
.config/config.ron
··· 47 47 ), 48 48 "p": EditPriority, 49 49 "n": EditName, 50 + "d": EditDue, 50 51 "3": SwitchTo( 51 52 page: Todo(TaskList), 52 53 ), ··· 80 81 }, 81 82 ), 82 83 ), 83 - ) 84 + )
+2 -1
.config/default_config.ron
··· 35 35 "2": SwitchTo(page: Todo(Inspector)), 36 36 "3": SwitchTo(page: Todo(TaskList)), 37 37 "n": EditName, 38 - "p": EditPriority 38 + "p": EditPriority, 39 + "d": EditDue 39 40 }, 40 41 ), 41 42 tasklist: (
+3
Cargo.lock
··· 1224 1224 checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" 1225 1225 dependencies = [ 1226 1226 "iana-time-zone", 1227 + "js-sys", 1227 1228 "num-traits", 1228 1229 "serde", 1230 + "wasm-bindgen", 1229 1231 "windows-link", 1230 1232 ] 1231 1233 ··· 2318 2320 "async-recursion", 2319 2321 "async-trait", 2320 2322 "better-panic", 2323 + "chrono", 2321 2324 "clap", 2322 2325 "color-eyre", 2323 2326 "crossterm",
+1
Cargo.toml
··· 86 86 notify = "8.2.0" 87 87 tree = {workspace = true} 88 88 async-recursion = "1.1.1" 89 + chrono = "0.4.44" 89 90 90 91 [build-dependencies] 91 92 anyhow = "1.0.102"
+105 -2
src/tui/components/todo/inspector/mod.rs
··· 15 15 Signal, 16 16 components::{Component, DEFAULT_NAME}, 17 17 }, 18 - types::{Group, KastenHandle, Priority, Task, TodoNode, TodoNodeKind}, 18 + types::{Due, Group, KastenHandle, Priority, Task, TodoNode, TodoNodeKind}, 19 19 }; 20 20 21 21 mod rootview; ··· 42 42 enum Edit { 43 43 Name, 44 44 Priority, 45 + Due, 45 46 } 46 47 47 48 impl Inspector<'_> { ··· 203 204 .block() 204 205 .cloned() 205 206 .expect("All of them should have blocks") 206 - .border_style(Style::default().fg(Color::Green)), 207 + .border_style(Style::default().fg(Color::Yellow)), 207 208 ); 208 209 209 210 priority.set_cursor_style(Style::default().reversed()); ··· 215 216 return Ok(Some(Signal::EnterRawText)); 216 217 } 217 218 219 + Signal::EditDue => { 220 + let kt = self.kh.read().await; 221 + 222 + let Some(ref inspecting) = self.inspecting else { 223 + return Ok(None); 224 + }; 225 + 226 + // if its finished, we arent going to edit the due date lol 227 + if let TodoNodeKind::Task(task) = 228 + &kt.todo_tree.get_node_by_nano_id(inspecting).data().kind 229 + && task.finished_at.is_some() 230 + { 231 + return Ok(None); 232 + } 233 + 234 + drop(kt); 235 + 236 + let due = match &mut self.render_data { 237 + RenderData::Task { widget } => &mut widget.due_finished_at, 238 + _ => return Ok(None), 239 + }; 240 + 241 + due.set_block( 242 + due.block() 243 + .cloned() 244 + .expect("All of them should have blocks") 245 + .border_style(Style::default().fg(Color::Green)), 246 + ); 247 + 248 + due.set_cursor_style(Style::default().reversed()); 249 + due.set_cursor_line_style(Style::default().underlined()); 250 + due.move_cursor(CursorMove::WordBack); 251 + due.delete_line_by_end(); 252 + 253 + self.editing = Some(Edit::Due); 254 + return Ok(Some(Signal::EnterRawText)); 255 + } 256 + 218 257 Signal::Refresh => { 219 258 self.refresh().await; 220 259 } ··· 339 378 priority.set_block( 340 379 priority 341 380 .block() 381 + .cloned() 382 + .expect("All of them should have blocks") 383 + .border_style(Style::default().fg(Color::Red)), 384 + ); 385 + } 386 + 387 + Ok(None) 388 + } 389 + 390 + Some(Edit::Due) => { 391 + let due = match &mut self.render_data { 392 + RenderData::Task { widget } => &mut widget.due_finished_at, 393 + _ => return Ok(None), 394 + }; 395 + 396 + if key.code != KeyCode::Enter { 397 + due.input_without_shortcuts(key); 398 + } 399 + 400 + let due_str = due.lines()[0].as_str(); 401 + 402 + if let Ok(new_due) = Due::try_from(due_str) { 403 + due.set_block( 404 + due.block() 405 + .cloned() 406 + .expect("All of them should have blocks") 407 + .border_style(Style::default().fg(Color::Green)), 408 + ); 409 + 410 + if key.code == KeyCode::Enter { 411 + self.editing = None; 412 + signal_tx.send(Signal::ExitRawText)?; 413 + 414 + due.set_cursor_style(Style::reset()); 415 + due.set_cursor_line_style(Style::reset()); 416 + 417 + due.set_block( 418 + due.block() 419 + .cloned() 420 + .expect("All of them should have blocks") 421 + .border_style(Style::default().fg(Color::Reset)), 422 + ); 423 + 424 + let id = self 425 + .inspecting 426 + .clone() 427 + .expect("Invariant Broken, this must be some id"); 428 + 429 + let kt = self.kh.read().await; 430 + 431 + match &self.render_data { 432 + RenderData::Task { .. } => { 433 + Task::alter_due(id.clone(), new_due.into(), &kt).await?; 434 + } 435 + _ => unreachable!("Already returned above"), 436 + } 437 + 438 + drop(kt); 439 + 440 + return Ok(Some(Signal::Refresh)); 441 + } 442 + } else { 443 + due.set_block( 444 + due.block() 342 445 .cloned() 343 446 .expect("All of them should have blocks") 344 447 .border_style(Style::default().fg(Color::Red)),
+11 -11
src/tui/components/todo/inspector/taskview.rs
··· 11 11 pub struct TaskView<'text> { 12 12 pub name: TextArea<'text>, 13 13 pub priority: TextArea<'text>, 14 + pub due_finished_at: TextArea<'text>, 14 15 parent_group: Paragraph<'text>, 15 - due_finished_at: Paragraph<'text>, 16 16 layouts: Layouts, 17 17 } 18 18 ··· 52 52 priority.set_cursor_line_style(Style::reset()); 53 53 54 54 let due_finished_at = { 55 - value.finished_at().map_or_else( 56 - || { 57 - value.due().map_or_else( 58 - || Paragraph::new("None").block(Block::bordered().title("Due")), 59 - |due| Paragraph::new(due).block(Block::bordered().title("Due")), 60 - ) 61 - }, 62 - |finished| Paragraph::new(finished).block(Block::bordered().title("Finished At")), 63 - ) 64 - }; 55 + let (title, content) = value.finished_at().map_or_else( 56 + || ("[D]ue", value.due.to_string()), 57 + |finished| ("[F]inished At", finished), 58 + ); 65 59 60 + let mut textarea = TextArea::new(vec![content]); 61 + textarea.set_block(Block::bordered().title(title)); 62 + textarea.set_cursor_style(Style::reset()); 63 + textarea.set_cursor_line_style(Style::reset()); 64 + textarea 65 + }; 66 66 Self { 67 67 name, 68 68 priority,
+6 -4
src/tui/components/todo/tasklist.rs
··· 94 94 95 95 let name = Span::from(task.name.clone()).style(Style::new().fg(color.into())); 96 96 let group = Span::from(task.group.name.clone()).style(Style::new().fg(color.into())); 97 - let due_priority = task 98 - .due() 99 - .map_or_else(|| Span::from(task.priority.to_string()), Span::from) 100 - .style(Style::new().fg(color.into())); 97 + let due_priority = Span::from(if task.due.has_date() { 98 + task.due.to_string() 99 + } else { 100 + task.priority.to_string() 101 + }) 102 + .style(Style::new().fg(color.into())); 101 103 102 104 Self { 103 105 name,
+4
src/tui/signal.rs
··· 74 74 /// Only works with the inspector 75 75 EditPriority, 76 76 77 + /// Edit the `DueDate` of a `Task` 78 + /// Only works with the inspector 79 + EditDue, 80 + 77 81 /// Internal Signal that tells the app to resume interpreting keys 78 82 ExitRawText, 79 83
+237
src/types/due.rs
··· 1 + use crate::types::frontmatter; 2 + use chrono::Datelike; 3 + use std::fmt::Display; 4 + 5 + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 6 + pub struct Due(Option<dto::DateTime>); 7 + 8 + impl Due { 9 + pub const fn has_date(&self) -> bool { 10 + self.0.is_some() 11 + } 12 + } 13 + 14 + impl From<Option<dto::DateTime>> for Due { 15 + fn from(value: Option<dto::DateTime>) -> Self { 16 + Self(value) 17 + } 18 + } 19 + 20 + impl From<Due> for Option<dto::DateTime> { 21 + fn from(value: Due) -> Self { 22 + value.0 23 + } 24 + } 25 + 26 + impl Display for Due { 27 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 28 + let str = self.0.map_or_else( 29 + || "None".to_string(), 30 + |d| d.format(frontmatter::DATE_FMT_STR).to_string(), 31 + ); 32 + write!(f, "{str}") 33 + } 34 + } 35 + 36 + impl TryFrom<&str> for Due { 37 + type Error = color_eyre::Report; 38 + fn try_from(value: &str) -> Result<Self, Self::Error> { 39 + parse_due_date(value) 40 + .map(Into::into) 41 + .map_err(|e| color_eyre::eyre::eyre!(e)) 42 + } 43 + } 44 + 45 + //NOTE: everything after this was written by claude because I am 46 + // way too fucking lazy to do all this due date parsing bs, I did audit it though. 47 + 48 + const fn naive_date_to_dt(d: chrono::NaiveDate) -> chrono::NaiveDateTime { 49 + d.and_hms_opt(23, 59, 59).unwrap() 50 + } 51 + 52 + #[expect(clippy::cast_possible_truncation)] 53 + fn parse_relative_offset(lower: &str, today: chrono::NaiveDate) -> Option<chrono::NaiveDate> { 54 + let lower = lower.strip_prefix("in ")?.trim(); 55 + let mut parts = lower.splitn(2, ' '); 56 + let n: i64 = parts.next()?.parse().ok()?; 57 + let unit = parts.next()?.trim_end_matches('s'); 58 + match unit { 59 + "day" => Some(today + chrono::Duration::days(n)), 60 + "week" => Some(today + chrono::Duration::weeks(n)), 61 + "month" => { 62 + let total_months = today.month0().cast_signed() + n as i32; 63 + let year = today.year() + total_months / 12; 64 + let month = (total_months % 12).cast_unsigned() + 1; 65 + chrono::NaiveDate::from_ymd_opt(year, month, today.day()).or_else(|| { 66 + chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).and_then(|d| d.pred_opt()) 67 + }) 68 + } 69 + "year" => { 70 + chrono::NaiveDate::from_ymd_opt(today.year() + n as i32, today.month(), today.day()) 71 + } 72 + _ => None, 73 + } 74 + } 75 + 76 + fn parse_weekday(lower: &str, today: chrono::NaiveDate) -> Option<chrono::NaiveDate> { 77 + use chrono::Weekday::{Fri, Mon, Sat, Sun, Thu, Tue, Wed}; 78 + let (force_next, word) = lower 79 + .strip_prefix("next ") 80 + .map_or((false, lower), |rest| (true, rest.trim())); 81 + let target = match word { 82 + "monday" | "mon" => Mon, 83 + "tuesday" | "tue" | "tues" => Tue, 84 + "wednesday" | "wed" => Wed, 85 + "thursday" | "thu" | "thur" | "thurs" => Thu, 86 + "friday" | "fri" => Fri, 87 + "saturday" | "sat" => Sat, 88 + "sunday" | "sun" => Sun, 89 + _ => return None, 90 + }; 91 + let mut days_ahead = i64::from(target.num_days_from_monday()) 92 + - i64::from(today.weekday().num_days_from_monday()); 93 + if days_ahead <= 0 || force_next { 94 + days_ahead += 7; 95 + } 96 + Some(today + chrono::Duration::days(days_ahead)) 97 + } 98 + 99 + #[expect(clippy::too_many_lines)] 100 + fn parse_due_date(s: &str) -> Result<Option<dto::DateTime>, String> { 101 + use chrono::{Datelike, Local, NaiveDate, NaiveDateTime}; 102 + 103 + let s = s.trim(); 104 + 105 + // 1. Empty / explicit "no due date" sentinels 106 + if s.is_empty() 107 + || matches!( 108 + s.to_lowercase().as_str(), 109 + "none" | "never" | "n/a" | "na" | "-" | "--" | "null" | "nil" | "no" | "no due date" 110 + ) 111 + { 112 + return Ok(None); 113 + } 114 + 115 + let lower = s.to_lowercase(); 116 + let today = Local::now().date_naive(); 117 + 118 + // 2. Relative human words 119 + let relative: Option<NaiveDate> = match lower.as_str() { 120 + "tomorrow" | "tmrw" | "tmr" => Some(today + chrono::Duration::days(1)), 121 + "yesterday" => Some(today - chrono::Duration::days(1)), 122 + "today" | "now" | "eod" | "end of day" => Some(today), 123 + "eow" | "end of week" => { 124 + let days = (7 - today.weekday().num_days_from_sunday()) % 7; 125 + Some(today + chrono::Duration::days(i64::from(days))) 126 + } 127 + "eom" | "end of month" => { 128 + let next = if today.month() == 12 { 129 + NaiveDate::from_ymd_opt(today.year() + 1, 1, 1) 130 + } else { 131 + NaiveDate::from_ymd_opt(today.year(), today.month() + 1, 1) 132 + }; 133 + next.and_then(|d| d.pred_opt()) 134 + } 135 + "eoy" | "end of year" => NaiveDate::from_ymd_opt(today.year(), 12, 31), 136 + _ => None, 137 + }; 138 + if let Some(d) = relative { 139 + return Ok(Some(naive_date_to_dt(d))); 140 + } 141 + 142 + // 3. "in N days/weeks/months/years" 143 + if let Some(d) = parse_relative_offset(&lower, today) { 144 + return Ok(Some(naive_date_to_dt(d))); 145 + } 146 + 147 + // 4. Named weekdays 148 + if let Some(d) = parse_weekday(&lower, today) { 149 + return Ok(Some(naive_date_to_dt(d))); 150 + } 151 + 152 + let s_noz = s.trim_end_matches('Z'); 153 + 154 + // 5. Datetime formats 155 + let datetime_fmts: &[&str] = &[ 156 + "%Y-%m-%dT%H:%M:%S", 157 + "%Y-%m-%dT%H:%M", 158 + "%Y-%m-%d %H:%M:%S", 159 + "%Y-%m-%d %H:%M", 160 + "%d/%m/%Y %H:%M:%S", 161 + "%d/%m/%Y %H:%M", 162 + "%m/%d/%Y %H:%M:%S", 163 + "%m/%d/%Y %H:%M", 164 + "%d-%m-%Y %H:%M", 165 + "%m-%d-%Y %H:%M", 166 + "%d %b %Y %H:%M", 167 + "%d %B %Y %H:%M", 168 + "%b %d %Y %H:%M", 169 + "%B %d %Y %H:%M", 170 + "%b %d, %Y %H:%M", 171 + "%B %d, %Y %H:%M", 172 + ]; 173 + for fmt in datetime_fmts { 174 + if let Ok(dt) = NaiveDateTime::parse_from_str(s_noz, fmt) { 175 + return Ok(Some(dt)); 176 + } 177 + } 178 + 179 + // 6. Date-only formats 180 + let date_fmts: &[&str] = &[ 181 + "%Y-%m-%d", 182 + "%Y/%m/%d", 183 + "%Y.%m.%d", 184 + "%d/%m/%Y", 185 + "%m/%d/%Y", 186 + "%d-%m-%Y", 187 + "%m-%d-%Y", 188 + "%d.%m.%Y", 189 + "%d %b %Y", 190 + "%d %B %Y", 191 + "%b %d %Y", 192 + "%B %d %Y", 193 + "%b %d, %Y", 194 + "%B %d, %Y", 195 + "%b %Y", 196 + "%B %Y", 197 + "%Y%m%d", 198 + ]; 199 + for fmt in date_fmts { 200 + if let Ok(d) = NaiveDate::parse_from_str(s_noz, fmt) { 201 + return Ok(Some(naive_date_to_dt(d))); 202 + } 203 + } 204 + 205 + // 6b. Yearless shorthand e.g. "4/13" or "4-13" — fill in current year 206 + let yearless_fmts: &[&str] = &["%m/%d", "%m-%d"]; 207 + for fmt in yearless_fmts { 208 + if let Ok(d) = NaiveDate::parse_from_str( 209 + &format!("{}/{}", s_noz, today.year()), 210 + &format!("{}/{}", fmt, "%Y"), 211 + ) { 212 + return Ok(Some(naive_date_to_dt(d))); 213 + } 214 + } 215 + 216 + // 7. Unix timestamp (seconds or milliseconds) 217 + if let Ok(n) = s.parse::<i64>() { 218 + let secs = if n > 10_000_000_000 { n / 1000 } else { n }; 219 + if let Some(dt) = chrono::DateTime::from_timestamp(secs, 0) { 220 + return Ok(Some(dt.naive_utc())); 221 + } 222 + } 223 + 224 + // 8. Strip noise and retry date-only 225 + let stripped: String = s 226 + .chars() 227 + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == ' ') 228 + .collect(); 229 + let stripped = stripped.trim(); 230 + for fmt in date_fmts { 231 + if let Ok(d) = NaiveDate::parse_from_str(stripped, fmt) { 232 + return Ok(Some(naive_date_to_dt(d))); 233 + } 234 + } 235 + 236 + Err(format!("could not parse {s:?} as a due date")) 237 + }
+3
src/types/mod.rs
··· 11 11 pub use zettel::Zettel; 12 12 pub use zettel::ZettelId; 13 13 14 + mod due; 15 + pub use due::Due; 16 + 14 17 mod group; 15 18 pub use group::Group; 16 19
+16 -6
src/types/task.rs
··· 4 4 TaskEntity, TaskModelEx, ZettelEntity, 5 5 }; 6 6 7 - use crate::types::{Group, Kasten, Priority, Zettel, frontmatter}; 7 + use crate::types::{Due, Group, Kasten, Priority, Zettel, frontmatter}; 8 8 9 9 /// a `Task` that you have to complete! 10 10 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] ··· 15 15 pub id: NanoId, 16 16 pub name: String, 17 17 pub priority: Priority, 18 - pub due: Option<DateTime>, 18 + pub due: Due, 19 19 pub group_id: NanoId, 20 20 pub finished_at: Option<DateTime>, 21 21 pub created_at: DateTime, ··· 129 129 Ok(()) 130 130 } 131 131 132 - pub fn due(&self) -> Option<String> { 133 - self.due 134 - .map(|due| due.format(frontmatter::DATE_FMT_STR).to_string()) 132 + pub async fn alter_due(id: NanoId, new_due: Option<DateTime>, kt: &Kasten) -> Result<()> { 133 + TaskEntity::load() 134 + .filter_by_nano_id(id) 135 + .one(&kt.db) 136 + .await? 137 + .expect("Must exist") 138 + .into_active_model() 139 + .set_due(new_due) 140 + .update(&kt.db) 141 + .await?; 142 + 143 + Ok(()) 135 144 } 145 + 136 146 pub fn finished_at(&self) -> Option<String> { 137 147 self.finished_at 138 148 .map(|finished_at| finished_at.format(frontmatter::DATE_FMT_STR).to_string()) ··· 156 166 id: value.nano_id, 157 167 name: value.name, 158 168 priority: value.priority.into(), 159 - due: value.due, 169 + due: value.due.into(), 160 170 group_id: value.group_id, 161 171 finished_at: value.finished_at, 162 172 created_at: value.created_at,