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.

Merge pull request #17 from suri-codes/todo

Todo

authored by

Surendra Jammishetti and committed by
GitHub
1fe8c9b9 62059178

+879 -175
+30 -27
.config/config.ron
··· 2 2 directory: "/Users/suri/dev/projects/filaments/ZettelKasten", 3 3 global_key_binds: { 4 4 "down": MoveDown, 5 - "ctrl-c": Quit, 6 - "up": MoveUp, 7 5 "ctrl-z": Suspend, 6 + "up": MoveUp, 7 + "ctrl-c": Quit, 8 8 }, 9 9 zk: ( 10 10 keybinds: { 11 + "ctrl-n": NewZettel, 11 12 "enter": OpenZettel, 12 13 "tab": SwitchTo( 13 14 page: Todo(Explorer), 14 15 ), 15 - "ctrl-n": NewZettel, 16 16 }, 17 17 ), 18 18 todo: ( 19 19 explorer: ( 20 20 keybinds: { 21 - "j": MoveDown, 21 + "k": MoveUp, 22 22 "1": SwitchTo( 23 23 page: Todo(Explorer), 24 24 ), 25 - "t": NewTask, 26 - "2": SwitchTo( 27 - page: Todo(Inspector), 25 + "tab": SwitchTo( 26 + page: Zk, 27 + ), 28 + "3": SwitchTo( 29 + page: Todo(TaskList), 28 30 ), 29 - "shift-g": NewGroup, 30 31 "g": NewSubGroup, 31 32 "enter": SwitchTo( 32 33 page: Todo(Inspector), 33 34 ), 34 - "tab": SwitchTo( 35 - page: Zk, 36 - ), 37 - "3": SwitchTo( 38 - page: Todo(TaskList), 35 + "shift-g": NewGroup, 36 + "t": NewTask, 37 + "j": MoveDown, 38 + "2": SwitchTo( 39 + page: Todo(Inspector), 39 40 ), 40 - "k": MoveUp, 41 41 }, 42 42 ), 43 43 inspector: ( 44 44 keybinds: { 45 + "p": EditPriority, 45 46 "tab": SwitchTo( 46 47 page: Zk, 47 48 ), 48 - "p": EditPriority, 49 49 "n": EditName, 50 + "d": EditDue, 51 + "1": SwitchTo( 52 + page: Todo(Explorer), 53 + ), 50 54 "3": SwitchTo( 51 55 page: Todo(TaskList), 52 56 ), 53 57 "2": SwitchTo( 54 58 page: Todo(Inspector), 55 59 ), 56 - "1": SwitchTo( 57 - page: Todo(Explorer), 58 - ), 60 + "o": OpenZettel, 61 + "f": ToggleFinish, 59 62 }, 60 63 ), 61 64 tasklist: ( 62 65 keybinds: { 63 - "tab": SwitchTo( 64 - page: Zk, 65 - ), 66 - "3": SwitchTo( 67 - page: Todo(TaskList), 66 + "enter": SwitchTo( 67 + page: Todo(Inspector), 68 68 ), 69 69 "2": SwitchTo( 70 70 page: Todo(Inspector), 71 71 ), 72 - "enter": SwitchTo( 73 - page: Todo(Inspector), 74 - ), 75 72 "1": SwitchTo( 76 73 page: Todo(Explorer), 77 74 ), 78 - "k": MoveUp, 75 + "3": SwitchTo( 76 + page: Todo(TaskList), 77 + ), 79 78 "j": MoveDown, 79 + "tab": SwitchTo( 80 + page: Zk, 81 + ), 82 + "k": MoveUp, 80 83 }, 81 84 ), 82 85 ),
+4 -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, 40 + "o": OpenZettel, 41 + "f": ToggleFinish 39 42 }, 40 43 ), 41 44 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"
+1 -1
justfile
··· 20 20 test: 21 21 cargo nextest r {{_cargo_flags}} 22 22 reset: 23 - rm -rf ZettleKasten 23 + rm -rf ./ZettelKasten 24 24 rm -rf ./.data 25 25 cargo run -- init 26 26 cargo run
+9 -1
src/tui/app.rs
··· 11 11 use crate::{ 12 12 config::Config, 13 13 tui::{Event, Tui, components::Viewport}, 14 - types::{KastenHandle, ZettelId}, 14 + types::{KastenHandle, TodoTree, ZettelId}, 15 15 }; 16 16 17 17 pub use crate::tui::components::TodoRegion; ··· 234 234 235 235 self.signal_tx.send(Signal::ClosedZettel { zid })?; 236 236 237 + self.signal_tx.send(Signal::Refresh)?; 238 + 237 239 tui.terminal.clear()?; 238 240 tui.enter()?; 239 241 } ··· 241 243 Signal::SwitchTo { page } => { 242 244 info!("Switched page to {page:#?}"); 243 245 self.page = page; 246 + } 247 + 248 + Signal::Refresh => { 249 + let mut kt = self.kh.write().await; 250 + // fuck it we just fully rebuild the tree, how computationally expensive could it even be 251 + kt.todo_tree = TodoTree::construct(&kt.db).await.expect("Must not error"); 244 252 } 245 253 246 254 Signal::Suspend => self.should_suspend = true,
+7 -1
src/tui/components/todo/explorer.rs
··· 125 125 .bg(g.tag.color) 126 126 .fg(Color::Black), 127 127 TodoNodeKind::Task(ref t) => { 128 - Span::from(format!(" {}", t.name.clone())).fg(t.group.tag.color) 128 + let mut name = Span::from(format!(" {}", t.name.clone())).fg(t.group.tag.color); 129 + 130 + if t.finished_at.is_some() { 131 + name = name.add_modifier(Modifier::CROSSED_OUT); 132 + } 133 + 134 + name 129 135 } 130 136 TodoNodeKind::Root => Span::from("THIS SHOULD NOT BE VISIBLE"), 131 137 };
+19 -8
src/tui/components/todo/inspector/groupview.rs
··· 5 5 }; 6 6 use ratatui_textarea::TextArea; 7 7 8 - use crate::types::Group; 8 + use crate::{ 9 + tui::components::preview::Preview, 10 + types::{Group, Index}, 11 + }; 9 12 10 13 #[derive(Debug, Clone)] 11 14 pub struct GroupView<'text> { 12 15 pub name: TextArea<'text>, 13 16 pub priority: TextArea<'text>, 14 17 created_at: Paragraph<'text>, 18 + preview: Preview<'text>, 15 19 layouts: Layouts, 16 20 } 17 21 ··· 26 30 Self { 27 31 left_content: Layout::horizontal(vec![ 28 32 Constraint::Percentage(30), 33 + Constraint::Min(1), 29 34 Constraint::Fill(100), 30 35 ]), 31 36 name_priority_created_at: Layout::vertical(vec![ ··· 38 43 } 39 44 } 40 45 41 - impl From<&Group> for GroupView<'_> { 42 - fn from(value: &Group) -> Self { 43 - let mut name = TextArea::new(vec![value.name.clone()]); 46 + impl From<(&Group, &Index)> for GroupView<'_> { 47 + fn from(value: (&Group, &Index)) -> Self { 48 + let group = value.0; 49 + let idx = value.1; 50 + let mut name = TextArea::new(vec![group.name.clone()]); 44 51 name.set_block(Block::bordered().title("[N]ame")); 45 52 name.set_cursor_style(Style::reset()); 46 53 name.set_cursor_line_style(Style::reset()); 47 54 48 - let mut priority = TextArea::new(vec![value.priority.to_string()]); 55 + let mut priority = TextArea::new(vec![group.priority.to_string()]); 49 56 priority.set_block(Block::bordered().title("[P]riority")); 50 57 priority.set_cursor_style(Style::reset()); 51 58 priority.set_cursor_line_style(Style::reset()); 52 59 60 + let preview = idx.get_zod(&group.zettel.id).body.clone().into(); 61 + 53 62 Self { 54 63 name, 55 64 priority, 56 - created_at: Paragraph::new(value.created_at()) 65 + created_at: Paragraph::new(group.created_at()) 57 66 .block(Block::bordered().title("Created At")), 67 + preview, 58 68 layouts: Layouts::default(), 59 69 } 60 70 } ··· 65 75 where 66 76 Self: Sized, 67 77 { 68 - let (name_rect, priority_rect, created_at, _content_rect) = { 78 + let (name_rect, priority_rect, created_at, content_rect) = { 69 79 let rects = self.layouts.left_content.split(area); 70 80 let l_rects = self.layouts.name_priority_created_at.split(rects[0]); 71 81 72 - (l_rects[0], l_rects[1], l_rects[2], rects[1]) 82 + (l_rects[0], l_rects[1], l_rects[2], rects[2]) 73 83 }; 74 84 75 85 self.name.render(name_rect, buf); 76 86 self.priority.render(priority_rect, buf); 77 87 self.created_at.render(created_at, buf); 88 + self.preview.render(content_rect, buf); 78 89 } 79 90 }
+238 -16
src/tui/components/todo/inspector/mod.rs
··· 15 15 Signal, 16 16 components::{Component, DEFAULT_NAME}, 17 17 }, 18 - types::{Group, KastenHandle, 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<'_> { 48 - pub fn new(kh: KastenHandle, node: &TodoNode) -> Self { 49 + pub async fn new(kh: KastenHandle, node: &TodoNode) -> Self { 49 50 let margins = Layout::new(Direction::Horizontal, [Constraint::Percentage(100)]) 50 51 .horizontal_margin(3) 51 52 .vertical_margin(2); ··· 58 59 .border_type(BorderType::Rounded); 59 60 60 61 let mut nanoid = None; 62 + let kt = kh.read().await; 61 63 62 64 let render_data = match node.kind { 63 65 TodoNodeKind::Root => RenderData::Root { ··· 67 69 nanoid = Some(group.id.clone()); 68 70 69 71 RenderData::Group { 70 - widget: Box::new(GroupView::from(&**group)), 72 + widget: Box::new(GroupView::from((&**group, &kt.index))), 71 73 } 72 74 } 73 75 TodoNodeKind::Task(ref task) => { 74 76 nanoid = Some(task.id.clone()); 75 77 76 78 RenderData::Task { 77 - widget: Box::new(TaskView::from(&**task)), 79 + widget: Box::new(TaskView::from((&**task, &kt.index))), 78 80 } 79 81 } 80 82 }; 83 + 84 + drop(kt); 81 85 82 86 Self { 83 87 render_data, ··· 112 116 .border_type(BorderType::Rounded); 113 117 } 114 118 115 - pub fn inspect(&mut self, node: &TodoNode) { 119 + pub async fn inspect(&mut self, node: &TodoNode) { 120 + let kt = self.kh.read().await; 121 + 116 122 self.render_data = match node.kind { 117 123 TodoNodeKind::Root => { 118 124 self.inspecting = None; ··· 123 129 TodoNodeKind::Group(ref group) => { 124 130 self.inspecting = Some(group.id.clone()); 125 131 RenderData::Group { 126 - widget: Box::new(GroupView::from(&**group)), 132 + widget: Box::new(GroupView::from((&**group, &kt.index))), 127 133 } 128 134 } 129 135 TodoNodeKind::Task(ref task) => { 130 136 self.inspecting = Some(task.id.clone()); 131 137 RenderData::Task { 132 - widget: Box::new(TaskView::from(&**task)), 138 + widget: Box::new(TaskView::from((&**task, &kt.index))), 133 139 } 134 140 } 135 141 } 136 142 } 143 + 144 + async fn refresh(&mut self) { 145 + // cheaper to clone this than the node 146 + let kh = self.kh.clone(); 147 + let kt = kh.read().await; 148 + 149 + let Some(ref inspecting) = self.inspecting else { 150 + return; 151 + }; 152 + let node = kt.todo_tree.get_node_by_nano_id(inspecting).data(); 153 + self.inspect(node).await; 154 + 155 + drop(kt); 156 + } 137 157 } 138 158 139 159 pub enum RenderData<'text> { ··· 149 169 Ok(()) 150 170 } 151 171 172 + #[expect(clippy::too_many_lines)] 152 173 async fn update(&mut self, signal: Signal) -> color_eyre::Result<Option<Signal>> { 153 174 match signal { 175 + Signal::SwitchTo { 176 + page: crate::tui::Page::Zk, 177 + } => { 178 + self.is_active = false; 179 + 180 + self.set_inactive(); 181 + } 182 + 154 183 Signal::EditName => { 155 184 let name = match &mut self.render_data { 156 185 RenderData::Root { widget: _ } => return Ok(None), ··· 189 218 .block() 190 219 .cloned() 191 220 .expect("All of them should have blocks") 192 - .border_style(Style::default().fg(Color::Green)), 221 + .border_style(Style::default().fg(Color::Yellow)), 193 222 ); 194 223 195 224 priority.set_cursor_style(Style::default().reversed()); 196 225 priority.set_cursor_line_style(Style::default().underlined()); 226 + priority.move_cursor(CursorMove::WordBack); 227 + priority.delete_line_by_end(); 197 228 198 229 self.editing = Some(Edit::Priority); 199 230 return Ok(Some(Signal::EnterRawText)); 200 231 } 201 232 233 + Signal::EditDue => { 234 + let kt = self.kh.read().await; 235 + 236 + let Some(ref inspecting) = self.inspecting else { 237 + return Ok(None); 238 + }; 239 + 240 + // if its finished, we arent going to edit the due date lol 241 + if let TodoNodeKind::Task(task) = 242 + &kt.todo_tree.get_node_by_nano_id(inspecting).data().kind 243 + && task.finished_at.is_some() 244 + { 245 + return Ok(None); 246 + } 247 + 248 + drop(kt); 249 + 250 + let due = match &mut self.render_data { 251 + RenderData::Task { widget } => &mut widget.due_finished_at, 252 + _ => return Ok(None), 253 + }; 254 + 255 + due.set_block( 256 + due.block() 257 + .cloned() 258 + .expect("All of them should have blocks") 259 + .border_style(Style::default().fg(Color::Green)), 260 + ); 261 + 262 + due.set_cursor_style(Style::default().reversed()); 263 + due.set_cursor_line_style(Style::default().underlined()); 264 + due.move_cursor(CursorMove::WordBack); 265 + due.delete_line_by_end(); 266 + 267 + self.editing = Some(Edit::Due); 268 + return Ok(Some(Signal::EnterRawText)); 269 + } 270 + 271 + Signal::Refresh => { 272 + self.refresh().await; 273 + } 274 + 275 + Signal::OpenZettel if self.is_active => { 276 + let Some(ref curr) = self.inspecting else { 277 + return Ok(None); 278 + }; 279 + 280 + let kt = self.kh.read().await; 281 + 282 + let node = kt.todo_tree.get_node_by_nano_id(curr).data(); 283 + 284 + let zid = match &node.kind { 285 + TodoNodeKind::Root => return Ok(None), 286 + TodoNodeKind::Group(group) => &group.zettel.id, 287 + TodoNodeKind::Task(task) => &task.zettel.id, 288 + }; 289 + 290 + let path = kt.index.get_zod(zid).path.clone(); 291 + drop(kt); 292 + return Ok(Some(Signal::Helix { path })); 293 + } 294 + 295 + Signal::ToggleFinish if self.is_active => { 296 + let Some(ref curr) = self.inspecting else { 297 + return Ok(None); 298 + }; 299 + 300 + let kt = self.kh.write().await; 301 + 302 + let node = kt.todo_tree.get_node_by_nano_id(curr).data(); 303 + 304 + let TodoNodeKind::Task(t) = &node.kind else { 305 + return Ok(None); 306 + }; 307 + 308 + Task::toggle_finish(t.id.clone(), &kt).await?; 309 + 310 + drop(kt); 311 + return Ok(Some(Signal::Refresh)); 312 + } 313 + 202 314 _ => {} 203 315 } 204 316 Ok(None) 205 317 } 206 318 319 + #[expect(clippy::too_many_lines)] 207 320 async fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<Option<Signal>> { 208 321 let signal_tx = self 209 322 .signal_tx ··· 262 375 RenderData::Group { widget } => &mut widget.priority, 263 376 }; 264 377 265 - if key.code == KeyCode::Enter { 266 - priority.set_cursor_style(Style::reset()); 267 - priority.set_cursor_line_style(Style::reset()); 378 + // we dont want them entering into this 379 + if key.code != KeyCode::Enter { 380 + priority.input_without_shortcuts(key); 381 + } 382 + 383 + let priority_str = priority.lines()[0].as_str(); 268 384 385 + if let Ok(prio) = Priority::try_from(priority_str) { 269 386 priority.set_block( 270 387 priority 271 388 .block() 272 389 .cloned() 273 390 .expect("All of them should have blocks") 274 - .border_style(Style::default().fg(Color::Reset)), 391 + .border_style(Style::default().fg(Color::Green)), 392 + ); 393 + 394 + if key.code == KeyCode::Enter { 395 + self.editing = None; 396 + signal_tx.send(Signal::ExitRawText)?; 397 + 398 + priority.set_cursor_style(Style::reset()); 399 + priority.set_cursor_line_style(Style::reset()); 400 + 401 + priority.set_block( 402 + priority 403 + .block() 404 + .cloned() 405 + .expect("All of them should have blocks") 406 + .border_style(Style::default().fg(Color::Reset)), 407 + ); 408 + 409 + let id = self 410 + .inspecting 411 + .clone() 412 + .expect("Invariant Broken, this must be some id"); 413 + 414 + let kt = self.kh.read().await; 415 + 416 + match &self.render_data { 417 + RenderData::Task { .. } => { 418 + Task::alter_priority(id.clone(), prio, &kt).await?; 419 + } 420 + RenderData::Group { .. } => { 421 + Group::alter_priority(id.clone(), prio, &kt).await?; 422 + } 423 + RenderData::Root { .. } => unreachable!("Already returned above"), 424 + } 425 + 426 + drop(kt); 427 + 428 + return Ok(Some(Signal::Refresh)); 429 + } 430 + } else { 431 + priority.set_block( 432 + priority 433 + .block() 434 + .cloned() 435 + .expect("All of them should have blocks") 436 + .border_style(Style::default().fg(Color::Red)), 437 + ); 438 + } 439 + 440 + Ok(None) 441 + } 442 + 443 + Some(Edit::Due) => { 444 + let due = match &mut self.render_data { 445 + RenderData::Task { widget } => &mut widget.due_finished_at, 446 + _ => return Ok(None), 447 + }; 448 + 449 + if key.code != KeyCode::Enter { 450 + due.input_without_shortcuts(key); 451 + } 452 + 453 + let due_str = due.lines()[0].as_str(); 454 + 455 + if let Ok(new_due) = Due::try_from(due_str) { 456 + due.set_block( 457 + due.block() 458 + .cloned() 459 + .expect("All of them should have blocks") 460 + .border_style(Style::default().fg(Color::Green)), 275 461 ); 276 462 277 - self.editing = None; 278 - Ok(Some(Signal::ExitRawText)) 463 + if key.code == KeyCode::Enter { 464 + self.editing = None; 465 + signal_tx.send(Signal::ExitRawText)?; 466 + 467 + due.set_cursor_style(Style::reset()); 468 + due.set_cursor_line_style(Style::reset()); 469 + 470 + due.set_block( 471 + due.block() 472 + .cloned() 473 + .expect("All of them should have blocks") 474 + .border_style(Style::default().fg(Color::Reset)), 475 + ); 476 + 477 + let id = self 478 + .inspecting 479 + .clone() 480 + .expect("Invariant Broken, this must be some id"); 481 + 482 + let kt = self.kh.read().await; 483 + 484 + match &self.render_data { 485 + RenderData::Task { .. } => { 486 + Task::alter_due(id.clone(), new_due.into(), &kt).await?; 487 + } 488 + _ => unreachable!("Already returned above"), 489 + } 490 + 491 + drop(kt); 492 + 493 + return Ok(Some(Signal::Refresh)); 494 + } 279 495 } else { 280 - priority.input_without_shortcuts(key); 281 - Ok(None) 496 + due.set_block( 497 + due.block() 498 + .cloned() 499 + .expect("All of them should have blocks") 500 + .border_style(Style::default().fg(Color::Red)), 501 + ); 282 502 } 503 + 504 + Ok(None) 283 505 } 284 506 285 507 None => return Ok(None),
+32 -18
src/tui/components/todo/inspector/taskview.rs
··· 5 5 }; 6 6 use ratatui_textarea::TextArea; 7 7 8 - use crate::types::Task; 8 + use crate::{ 9 + tui::components::preview::Preview, 10 + types::{Index, Task}, 11 + }; 9 12 10 13 #[derive(Debug, Clone)] 11 14 pub struct TaskView<'text> { 12 15 pub name: TextArea<'text>, 13 16 pub priority: TextArea<'text>, 17 + pub due_finished_at: TextArea<'text>, 14 18 parent_group: Paragraph<'text>, 15 - due_finished_at: Paragraph<'text>, 19 + preview: Preview<'text>, 16 20 layouts: Layouts, 17 21 } 18 22 ··· 27 31 Self { 28 32 left_content: Layout::horizontal(vec![ 29 33 Constraint::Percentage(30), 34 + Constraint::Min(1), 30 35 Constraint::Fill(100), 31 36 ]), 37 + // .horizontal_margin(2), 32 38 name_priority_due_group: Layout::vertical(vec![ 33 39 Constraint::Min(3), 34 40 Constraint::Min(3), ··· 39 45 } 40 46 } 41 47 42 - impl From<&Task> for TaskView<'_> { 43 - fn from(value: &Task) -> Self { 44 - let mut name = TextArea::new(vec![value.name.clone()]); 48 + impl From<(&Task, &Index)> for TaskView<'_> { 49 + fn from(value: (&Task, &Index)) -> Self { 50 + let task = value.0; 51 + let idx = value.1; 52 + 53 + let mut name = TextArea::new(vec![task.name.clone()]); 45 54 name.set_block(Block::bordered().title("[N]ame")); 46 55 name.set_cursor_style(Style::reset()); 47 56 name.set_cursor_line_style(Style::reset()); 48 57 49 - let mut priority = TextArea::new(vec![value.priority.to_string()]); 58 + let mut priority = TextArea::new(vec![task.priority.to_string()]); 50 59 priority.set_block(Block::bordered().title("[P]riority")); 51 60 priority.set_cursor_style(Style::reset()); 52 61 priority.set_cursor_line_style(Style::reset()); 53 62 54 63 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 + let (title, content) = task.finished_at().map_or_else( 65 + || ("[D]ue", task.due.to_string()), 66 + |finished| ("[F]inished At", finished), 67 + ); 68 + 69 + let mut textarea = TextArea::new(vec![content]); 70 + textarea.set_block(Block::bordered().title(title)); 71 + textarea.set_cursor_style(Style::reset()); 72 + textarea.set_cursor_line_style(Style::reset()); 73 + textarea 64 74 }; 75 + 76 + let preview = idx.get_zod(&task.zettel.id).body.clone().into(); 65 77 66 78 Self { 67 79 name, 68 80 priority, 69 81 due_finished_at, 70 - parent_group: Paragraph::new(value.group.name.clone()) 82 + parent_group: Paragraph::new(task.group.name.clone()) 71 83 .block(Block::bordered().title("Group")), 72 84 layouts: Layouts::default(), 85 + preview, 73 86 } 74 87 } 75 88 } ··· 79 92 where 80 93 Self: Sized, 81 94 { 82 - let (name_rect, priority_rect, due_rect, group_rect, _content_rect) = { 95 + let (name_rect, priority_rect, due_rect, group_rect, content_rect) = { 83 96 let rects = self.layouts.left_content.split(area); 84 97 let l_rects = self.layouts.name_priority_due_group.split(rects[0]); 85 98 86 - (l_rects[0], l_rects[1], l_rects[2], l_rects[3], rects[1]) 99 + (l_rects[0], l_rects[1], l_rects[2], l_rects[3], rects[2]) 87 100 }; 88 101 89 102 self.name.render(name_rect, buf); 90 103 self.priority.render(priority_rect, buf); 91 104 self.due_finished_at.render(due_rect, buf); 92 105 self.parent_group.render(group_rect, buf); 106 + self.preview.render(content_rect, buf); 93 107 } 94 108 }
+10 -12
src/tui/components/todo/mod.rs
··· 13 13 14 14 use crate::{ 15 15 tui::{Page, Signal, components::Component}, 16 - types::{Group, KastenHandle, Priority, Task, TodoTree}, 16 + types::{Group, KastenHandle, Priority, Task}, 17 17 }; 18 18 19 19 mod explorer; ··· 83 83 .selected() 84 84 .and_then(|idx| task_list.id_list.get(idx)); 85 85 86 - let mut kt = self.kh.write().await; 87 - 88 - // fuck it we just fully rebuild the tree, how computationally expensive could it even be 89 - kt.todo_tree = TodoTree::construct(&kt.db).await.expect("Must not error"); 86 + let kt = self.kh.read().await; 90 87 91 88 let tree = &kt.todo_tree; 92 89 ··· 131 128 132 129 self.explorer = Some(explorer); 133 130 self.task_list = Some(task_list); 134 - self.update_inspector_from_selection().await; 135 131 } 136 132 137 133 async fn update_inspector_from_selection(&mut self) { ··· 169 165 }; 170 166 let tree = &self.kh.read().await.todo_tree.tree; 171 167 172 - inspector.inspect( 173 - tree.get(selected_node_id) 174 - .expect("Nodeid must be valid") 175 - .data(), 176 - ); 168 + inspector 169 + .inspect( 170 + tree.get(selected_node_id) 171 + .expect("Nodeid must be valid") 172 + .data(), 173 + ) 174 + .await; 177 175 } 178 176 } 179 177 ··· 237 235 ) 238 236 .expect("Node id must be valid"); 239 237 240 - let mut inspector: Inspector<'_> = Inspector::new(self.kh.clone(), first.data()); 238 + let mut inspector: Inspector<'_> = Inspector::new(self.kh.clone(), first.data()).await; 241 239 242 240 explorer.set_inactive(); 243 241 inspector.set_inactive();
+51 -29
src/tui/components/todo/tasklist.rs
··· 19 19 pub fn new(tree: &TodoTree, scope: &NodeId, state: ListState, width: u16) -> Self { 20 20 let mut id_list = vec![]; 21 21 22 - let render_list = List::new( 23 - tree.tree 24 - .traverse_pre_order(scope) 25 - .expect("This should not panic as the node id should exist inside") 26 - .zip( 27 - tree.tree 28 - .traverse_pre_order_ids(scope) 29 - .expect("This should not panic as the nodeid should exist inside"), 30 - ) 31 - .filter_map(|(node, id)| { 32 - let TodoNodeKind::Task(_) = node.data().kind else { 33 - return None; 34 - }; 22 + let mut items = tree 23 + .tree 24 + .traverse_pre_order(scope) 25 + .expect("This should not panic as the node id sohuld exist inside") 26 + .zip( 27 + tree.tree 28 + .traverse_pre_order_ids(scope) 29 + .expect("This should not panic as the nodeid should exist inside"), 30 + ) 31 + .filter(|(node, _)| { 32 + if let TodoNodeKind::Task(ref t) = node.data().kind 33 + && t.finished_at().is_none() 34 + { 35 + return true; 36 + } 37 + false 38 + }) 39 + .collect::<Vec<_>>(); 35 40 36 - let mut tli: TaskListItem<'_> = node.data().into(); 41 + items.sort_by(|(a, _), (b, _)| a.data().p_score.total_cmp(&b.data().p_score)); 37 42 38 - id_list.push(id); 43 + items.reverse(); 39 44 40 - tli.width = width; 41 - Some(Text::from(tli)) 42 - }), 43 - ) 45 + let render_list = List::new(items.into_iter().map(|(node, id)| { 46 + let TodoNodeKind::Task(_) = node.data().kind else { 47 + unreachable!("we already filtered for this earlier") 48 + }; 49 + 50 + let mut tli: TaskListItem<'_> = node.data().into(); 51 + 52 + id_list.push(id); 53 + 54 + tli.width = width; 55 + Text::from(tli) 56 + })) 44 57 .style(Color::White) 45 58 .highlight_style(Style::new().on_dark_gray()); 46 59 ··· 79 92 name: Span<'text>, 80 93 group: Span<'text>, 81 94 due_priority: Span<'text>, 95 + p_score: Span<'text>, 82 96 width: u16, 83 97 } 84 98 ··· 94 108 95 109 let name = Span::from(task.name.clone()).style(Style::new().fg(color.into())); 96 110 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())); 111 + let due_priority = Span::from(if task.due.has_date() { 112 + task.due.to_string() 113 + } else { 114 + task.priority.to_string() 115 + }) 116 + .style(Style::new().fg(color.into())); 117 + 118 + let p_score = 119 + Span::from(format!("{:.3}", value.p_score)).style(Style::new().fg(color.into())); 101 120 102 121 Self { 103 122 name, 104 123 group, 105 124 due_priority, 106 125 width: 0, 126 + p_score, 107 127 } 108 128 } 109 129 } ··· 111 131 impl<'text> From<TaskListItem<'text>> for Text<'text> { 112 132 fn from(value: TaskListItem<'text>) -> Self { 113 133 let total_width = value.width.saturating_sub(2) as usize; 114 - let name_col = total_width / 2; 115 - let due_content = value.due_priority.content.as_ref(); 116 - let due_col = due_content.len(); 117 - let group_col = total_width.saturating_sub(name_col + due_col); 134 + let name_col = 5 * total_width / 9; 135 + let p_score_col = 10; // e.g. "0.103" — fixed width 136 + let due_col = 22; // enough for "2026-04-22 11:59:59 PM" or a priority label 137 + let group_col = total_width.saturating_sub(name_col + p_score_col + due_col); 118 138 119 139 let name_str = format!("{:<width$}", value.name.content, width = name_col); 120 140 let group_str = format!("{:<width$}", value.group.content, width = group_col); 121 - let due_str = format!("{due_content:>due_col$}"); 141 + let p_score_str = format!("{:<width$}", value.p_score.content, width = p_score_col); 142 + let due_str = format!("{:>width$}", value.due_priority.content, width = due_col); 122 143 123 144 let name = Span::styled(name_str, value.name.style); 124 145 let group = Span::styled(group_str, value.group.style); 146 + let p_score = Span::styled(p_score_str, value.p_score.style); 125 147 let due = Span::styled(due_str, value.due_priority.style); 126 148 127 - Line::from(vec![name, group, due]).into() 149 + Line::from(vec![name, group, p_score, due]).into() 128 150 } 129 151 }
+2 -2
src/tui/components/zk/mod.rs
··· 11 11 types::{KastenHandle, Zettel}, 12 12 }; 13 13 14 - mod preview; 14 + pub mod preview; 15 15 mod search; 16 16 mod zettel_list; 17 17 mod zettel_view; ··· 328 328 self.update_views_from_zettel_list_selection().await?; 329 329 } 330 330 331 - Signal::OpenZettel => { 331 + Signal::OpenZettel if self.active => { 332 332 let Some(selcted) = zettel_list.state.selected() else { 333 333 return Ok(None); 334 334 };
+11 -3
src/tui/components/zk/preview.rs
··· 1 - use ratatui::{text::Text, widgets::Widget}; 1 + use ratatui::{ 2 + text::Text, 3 + widgets::{Block, BorderType, Borders, Paragraph, Widget}, 4 + }; 2 5 3 6 #[derive(Debug, Clone)] 4 7 pub struct Preview<'text> { 5 - content: Text<'text>, 8 + content: Paragraph<'text>, 6 9 } 7 10 8 11 impl From<String> for Preview<'_> { 9 12 fn from(value: String) -> Self { 10 13 Self { 11 - content: Text::from(value), 14 + content: Paragraph::new(Text::from(value)).block( 15 + Block::new() 16 + .borders(Borders::TOP | Borders::LEFT) 17 + .border_type(BorderType::Rounded) 18 + .title("Preview"), 19 + ), 12 20 } 13 21 } 14 22 }
+8
src/tui/signal.rs
··· 51 51 52 52 /// User asks to open a `Zettel` 53 53 OpenZettel, 54 + 54 55 /// The user is done editing a `Zettel` 55 56 ClosedZettel { 56 57 /// the id of the `Zettel` that was closed ··· 73 74 /// Edit the `Priority` of a `Task` or a `Group`. 74 75 /// Only works with the inspector 75 76 EditPriority, 77 + 78 + /// Edit the `DueDate` of a `Task` 79 + /// Only works with the inspector 80 + EditDue, 81 + 82 + /// Toggle whether a `Task` is finished or not. 83 + ToggleFinish, 76 84 77 85 /// Internal Signal that tells the app to resume interpreting keys 78 86 ExitRawText,
+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(pub 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 + }
+62 -43
src/types/group.rs
··· 28 28 } 29 29 30 30 impl Group { 31 - pub fn created_at(&self) -> String { 32 - self.created_at 33 - .format(frontmatter::DATE_FMT_STR) 34 - .to_string() 35 - } 36 - pub fn modified_at(&self) -> String { 37 - self.modified_at 38 - .format(frontmatter::DATE_FMT_STR) 39 - .to_string() 40 - } 41 - 42 - pub async fn alter_name( 43 - id: NanoId, 44 - new_name: impl Into<String>, 45 - kt: &mut Kasten, 46 - ) -> Result<()> { 47 - let new_name = new_name.into(); 48 - 49 - let g = GroupEntity::load() 50 - .filter_by_nano_id(id.clone()) 51 - .with(TagEntity) 52 - .with((ZettelEntity, TagEntity)) 53 - .one(&kt.db) 54 - .await? 55 - .expect("Invariant Broken: Must exist"); 56 - 57 - let tag_id = g.tag.as_ref().expect("Must be loaded").nano_id.clone(); 58 - 59 - let zettel_id = g.zettel_id.clone(); 60 - 61 - let _ = g 62 - .into_active_model() 63 - .set_name(new_name.as_str()) 64 - .update(&kt.db) 65 - .await?; 66 - 67 - Tag::alter_name(tag_id, &new_name, kt).await?; 68 - 69 - Zettel::alter_name(zettel_id.into(), &new_name, kt).await?; 70 - 71 - Ok(()) 72 - } 73 - 74 31 pub async fn new( 75 32 name: impl Into<String>, 76 33 parent_id: Option<NanoId>, ··· 124 81 125 82 kt.todo_tree.insert_group(&group); 126 83 Ok(group) 84 + } 85 + 86 + pub async fn alter_name( 87 + id: NanoId, 88 + new_name: impl Into<String>, 89 + kt: &mut Kasten, 90 + ) -> Result<()> { 91 + let new_name = new_name.into(); 92 + 93 + let g = GroupEntity::load() 94 + .filter_by_nano_id(id.clone()) 95 + .with(TagEntity) 96 + .with((ZettelEntity, TagEntity)) 97 + .one(&kt.db) 98 + .await? 99 + .expect("Invariant Broken: Must exist"); 100 + 101 + let tag_id = g.tag.as_ref().expect("Must be loaded").nano_id.clone(); 102 + 103 + let zettel_id = g.zettel_id.clone(); 104 + 105 + let _ = g 106 + .into_active_model() 107 + .set_name(new_name.as_str()) 108 + .update(&kt.db) 109 + .await?; 110 + 111 + Tag::alter_name(tag_id, &new_name, kt).await?; 112 + 113 + Zettel::alter_name(zettel_id.into(), &new_name, kt).await?; 114 + 115 + Ok(()) 116 + } 117 + 118 + pub async fn alter_priority(id: NanoId, new_prio: Priority, kt: &Kasten) -> Result<()> { 119 + GroupEntity::load() 120 + .filter_by_nano_id(id) 121 + .one(&kt.db) 122 + .await? 123 + .expect("Must exist") 124 + .into_active_model() 125 + .set_priority(new_prio) 126 + .update(&kt.db) 127 + .await?; 128 + 129 + Ok(()) 130 + } 131 + 132 + /// Calcualtes the `p_score` of this `Group` 133 + pub fn p_score(&self, parent_score: f64) -> f64 { 134 + self.priority.p_score() * parent_score 135 + } 136 + 137 + pub fn created_at(&self) -> String { 138 + self.created_at 139 + .format(frontmatter::DATE_FMT_STR) 140 + .to_string() 141 + } 142 + pub fn modified_at(&self) -> String { 143 + self.modified_at 144 + .format(frontmatter::DATE_FMT_STR) 145 + .to_string() 127 146 } 128 147 } 129 148
+41 -7
src/types/kasten/todo_tree.rs
··· 20 20 pub struct TodoNode { 21 21 pub depth: usize, 22 22 pub kind: TodoNodeKind, 23 + pub p_score: f64, 23 24 } 24 25 25 26 impl TodoNode { 26 - pub const fn new(kind: TodoNodeKind, depth: usize) -> Self { 27 - Self { depth, kind } 27 + pub const fn new(kind: TodoNodeKind, depth: usize, pscore: f64) -> Self { 28 + Self { 29 + depth, 30 + kind, 31 + p_score: pscore, 32 + } 28 33 } 29 34 } 30 35 ··· 40 45 let mut tree = Tree::<TodoNode>::new(); 41 46 let root_id = tree 42 47 .insert( 43 - Node::new(TodoNode::new(TodoNodeKind::Root, 0)), 48 + Node::new(TodoNode::new(TodoNodeKind::Root, 0, 1.0)), 44 49 InsertBehavior::AsRoot, 45 50 ) 46 51 .with_context(|| "Could not create root node.")?; ··· 64 69 65 70 for group in root_groups { 66 71 todo_tree 67 - .add_group_to_tree(db, &root_id, Box::new(group), 0) 72 + .add_group_to_tree(db, &root_id, Box::new(group), 0, 1.0) 68 73 .await?; 69 74 } 70 75 ··· 78 83 parent_node_id: &NodeId, 79 84 group: Box<Group>, 80 85 depth: usize, 86 + parent_p_score: f64, 81 87 ) -> Result<()> { 82 88 let group_id = group.id.clone(); 89 + 90 + let p_score = group.p_score(parent_p_score); 83 91 84 92 let group_node_id = self.tree.insert( 85 - Node::new(TodoNode::new(TodoNodeKind::Group(group), depth)), 93 + Node::new(TodoNode::new(TodoNodeKind::Group(group), depth, p_score)), 86 94 InsertBehavior::UnderNode(parent_node_id), 87 95 )?; 88 96 ··· 110 118 .collect(); 111 119 112 120 for task in tasks { 121 + let p_score = task.p_score(p_score); 122 + 113 123 let task_id = task.id.clone(); 114 124 let task_node_id = self.tree.insert( 115 - Node::new(TodoNode::new(TodoNodeKind::Task(Box::new(task)), depth + 1)), 125 + Node::new(TodoNode::new( 126 + TodoNodeKind::Task(Box::new(task)), 127 + depth + 1, 128 + p_score, 129 + )), 116 130 InsertBehavior::UnderNode(&group_node_id), 117 131 )?; 118 132 ··· 131 145 .collect(); 132 146 133 147 for group in children_groups { 134 - self.add_group_to_tree(db, &group_node_id, Box::new(group), depth + 1) 148 + self.add_group_to_tree(db, &group_node_id, Box::new(group), depth + 1, p_score) 135 149 .await?; 136 150 } 137 151 ··· 157 171 + 1 158 172 }; 159 173 174 + let parent_p_score = self 175 + .tree 176 + .get(&parent_node_id) 177 + .expect("must exist") 178 + .data() 179 + .p_score; 180 + 181 + let my_pscore = parent_p_score * group.priority.p_score(); 182 + 160 183 let inserted_node_id = self 161 184 .tree 162 185 .insert( 163 186 Node::new(TodoNode::new( 164 187 super::TodoNodeKind::Group(Box::new(group.clone())), 165 188 my_depth, 189 + my_pscore, 166 190 )), 167 191 tree::InsertBehavior::UnderNode(&parent_node_id), 168 192 ) ··· 189 213 .depth 190 214 + 1; 191 215 216 + let parent_p_score = self 217 + .tree 218 + .get(&parent_node_id) 219 + .expect("must exist") 220 + .data() 221 + .p_score; 222 + 223 + let my_pscore = task.p_score(parent_p_score); 224 + 192 225 let inserted_node_id = self 193 226 .tree 194 227 .insert( 195 228 Node::new(TodoNode::new( 196 229 super::TodoNodeKind::Task(Box::new(task.clone())), 197 230 my_depth, 231 + my_pscore, 198 232 )), 199 233 tree::InsertBehavior::UnderNode(&parent_node_id), 200 234 )
+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
+38
src/types/priority.rs
··· 1 1 use std::fmt::Display; 2 2 3 + use color_eyre::eyre::eyre; 3 4 use dto::PriorityDTO; 4 5 5 6 /// An Enum for the various `Priority` levels ··· 9 10 field1: PriorityDTO, 10 11 } 11 12 13 + impl Priority { 14 + pub const fn p_score(&self) -> f64 { 15 + match self.field1 { 16 + PriorityDTO::Asap => 1.0, 17 + PriorityDTO::High => 0.9, 18 + PriorityDTO::Medium => 0.75, 19 + PriorityDTO::Low => 0.5, 20 + PriorityDTO::Far => 0.25, 21 + } 22 + } 23 + } 24 + 12 25 impl From<PriorityDTO> for Priority { 13 26 fn from(value: PriorityDTO) -> Self { 14 27 Self { field1: value } ··· 26 39 write!(f, "{}", self.field1) 27 40 } 28 41 } 42 + 43 + impl TryFrom<&str> for Priority { 44 + type Error = color_eyre::Report; 45 + 46 + fn try_from(value: &str) -> Result<Self, Self::Error> { 47 + match value.to_ascii_lowercase().chars().next() { 48 + Some('a') => Ok(Self { 49 + field1: PriorityDTO::Asap, 50 + }), 51 + Some('h') => Ok(Self { 52 + field1: PriorityDTO::High, 53 + }), 54 + Some('m') => Ok(Self { 55 + field1: PriorityDTO::Medium, 56 + }), 57 + Some('l') => Ok(Self { 58 + field1: PriorityDTO::Low, 59 + }), 60 + Some('f') => Ok(Self { 61 + field1: PriorityDTO::Far, 62 + }), 63 + _ => Err(eyre!("Invalid Priority!")), 64 + } 65 + } 66 + }
+72 -6
src/types/task.rs
··· 1 + use chrono::Local; 1 2 use color_eyre::eyre::{Context, Result, eyre}; 2 3 use dto::{ 3 4 DateTime, GroupEntity, HasOne, IntoActiveModel as _, NanoId, TagEntity, TaskActiveModel, 4 5 TaskEntity, TaskModelEx, ZettelEntity, 5 6 }; 6 7 7 - use crate::types::{Group, Kasten, Priority, Zettel, frontmatter}; 8 + use crate::types::{Due, Group, Kasten, Priority, Zettel, frontmatter}; 8 9 9 10 /// a `Task` that you have to complete! 10 11 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] ··· 15 16 pub id: NanoId, 16 17 pub name: String, 17 18 pub priority: Priority, 18 - pub due: Option<DateTime>, 19 + pub due: Due, 19 20 pub group_id: NanoId, 20 21 pub finished_at: Option<DateTime>, 21 22 pub created_at: DateTime, ··· 115 116 Ok(()) 116 117 } 117 118 118 - pub fn due(&self) -> Option<String> { 119 - self.due 120 - .map(|due| due.format(frontmatter::DATE_FMT_STR).to_string()) 119 + pub async fn alter_priority(id: NanoId, new_prio: Priority, kt: &Kasten) -> Result<()> { 120 + TaskEntity::load() 121 + .filter_by_nano_id(id) 122 + .one(&kt.db) 123 + .await? 124 + .expect("Must exist") 125 + .into_active_model() 126 + .set_priority(new_prio) 127 + .update(&kt.db) 128 + .await?; 129 + 130 + Ok(()) 131 + } 132 + 133 + pub async fn alter_due(id: NanoId, new_due: Option<DateTime>, kt: &Kasten) -> Result<()> { 134 + TaskEntity::load() 135 + .filter_by_nano_id(id) 136 + .one(&kt.db) 137 + .await? 138 + .expect("Must exist") 139 + .into_active_model() 140 + .set_due(new_due) 141 + .update(&kt.db) 142 + .await?; 143 + 144 + Ok(()) 145 + } 146 + 147 + pub async fn toggle_finish(id: NanoId, kt: &Kasten) -> Result<()> { 148 + let now = Local::now().naive_local(); 149 + 150 + let model = TaskEntity::load() 151 + .filter_by_nano_id(id) 152 + .one(&kt.db) 153 + .await? 154 + .expect("Must exist"); 155 + 156 + let new_finished_at = if model.finished_at.is_some() { 157 + None 158 + } else { 159 + Some(now) 160 + }; 161 + 162 + model 163 + .into_active_model() 164 + .set_finished_at(new_finished_at) 165 + .update(&kt.db) 166 + .await?; 167 + 168 + Ok(()) 169 + } 170 + 171 + /// Calcualtes the `p_score` of this `Task` 172 + //NOTE: formula from claude 173 + #[expect(clippy::cast_precision_loss)] 174 + pub fn p_score(&self, parent_score: f64) -> f64 { 175 + let priority_score = self.priority.p_score(); // [0.0, 1.0] 176 + 177 + let urgency = self.due.0.map_or(0.0, |due| { 178 + let now = chrono::Local::now().naive_local(); 179 + let hours_remaining = (due - now).num_minutes() as f64 / 60.0; 180 + let decay = 72.0_f64; 181 + (-hours_remaining / decay).exp2() 182 + }); 183 + 184 + // base: priority alone. bonus: urgency on top, so any due date > no due date. 185 + // urgency is in (0.0, ~inf] so having a due date always adds to the score. 186 + (priority_score + urgency) * parent_score 121 187 } 122 188 pub fn finished_at(&self) -> Option<String> { 123 189 self.finished_at ··· 142 208 id: value.nano_id, 143 209 name: value.name, 144 210 priority: value.priority.into(), 145 - due: value.due, 211 + due: value.due.into(), 146 212 group_id: value.group_id, 147 213 finished_at: value.finished_at, 148 214 created_at: value.created_at,