diff --git a/crates/heph-core/src/filter.rs b/crates/heph-core/src/filter.rs index bd463ad..745be6b 100644 --- a/crates/heph-core/src/filter.rs +++ b/crates/heph-core/src/filter.rs @@ -177,6 +177,7 @@ mod tests { do_date: None, late_on: None, state: TaskState::Outstanding, + recurrence: None, tombstoned: false, project_id: None, canonical_context_id: None, diff --git a/crates/heph-core/src/ranking.rs b/crates/heph-core/src/ranking.rs index e586829..f077eba 100644 --- a/crates/heph-core/src/ranking.rs +++ b/crates/heph-core/src/ranking.rs @@ -34,6 +34,8 @@ pub struct RankedTask { pub late_on: Option, /// Lifecycle state. pub state: TaskState, + /// RRULE if this is a recurring task (surfaced for a `↻` marker + detail). + pub recurrence: Option, /// Whether tombstoned. pub tombstoned: bool, /// The task's project node id, if any (for `scope`). @@ -155,6 +157,7 @@ mod tests { do_date: None, late_on: None, state: TaskState::Outstanding, + recurrence: None, tombstoned: false, project_id: None, canonical_context_id: None, diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 9c18060..f751539 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -368,7 +368,7 @@ pub(super) fn list( ) -> Result> { let sql = " SELECT n.id, n.title, n.created_at, n.tombstoned, - t.attention, t.do_date, t.late_on, t.state, + t.attention, t.do_date, t.late_on, t.state, t.recurrence, (SELECT dst_id FROM links WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 ORDER BY created_at, id LIMIT 1) AS project_id, @@ -475,7 +475,7 @@ pub(super) fn health(conn: &Connection, owner: &str) -> Result { fn load_candidates(conn: &Connection, owner: &str) -> Result> { let sql = " SELECT n.id, n.title, n.created_at, n.tombstoned, - t.attention, t.do_date, t.late_on, t.state, + t.attention, t.do_date, t.late_on, t.state, t.recurrence, (SELECT dst_id FROM links WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 ORDER BY created_at, id LIMIT 1) AS project_id, @@ -508,6 +508,7 @@ fn ranked_from_row(row: &Row) -> rusqlite::Result { late_on: row.get("late_on")?, state: TaskState::parse(&row.get::<_, String>("state")?) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + recurrence: row.get("recurrence")?, tombstoned: row.get::<_, i64>("tombstoned")? != 0, project_id: row.get("project_id")?, canonical_context_id: row.get("ctx_id")?, diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 6ff1528..e5cbc1e 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -164,6 +164,14 @@ impl App { self.tasks.get(self.task_cursor) } + /// The title of a project node id, resolved from the sidebar. + pub fn project_name(&self, id: &str) -> Option { + self.sidebar.iter().find_map(|e| match e { + SidebarEntry::Project { id: pid, title } if pid == id => Some(title.clone()), + _ => None, + }) + } + /// The node to open in the editor for the highlighted task: its /// canonical-context doc (where the description/checklist live), falling /// back to the task node itself. diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index fbe0090..808c2ea 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -131,6 +131,38 @@ fn attention_style(a: Option) -> (char, Style) { } } +/// The dimmed, indented detail block shown under the selected task: project, +/// recurrence rule, and do/late dates (only the fields that are set). +fn task_detail_lines( + app: &App, + t: &heph_core::RankedTask, + today: chrono::NaiveDate, +) -> Vec> { + let dim = Style::default().fg(Color::DarkGray); + let mut lines = Vec::new(); + let mut field = |label: &str, value: String| { + lines.push(Line::from(Span::styled( + format!(" {label:8}{value}"), + dim, + ))); + }; + if let Some(pid) = &t.project_id { + if let Some(name) = app.project_name(pid) { + field("project:", name); + } + } + if let Some(rrule) = &t.recurrence { + field("recurs:", rrule.clone()); + } + if let Some(d) = t.do_date { + field("do:", fmt_date(d, today)); + } + if let Some(l) = t.late_on { + field("late:", fmt_date(l, today)); + } + lines +} + fn render_tasks(frame: &mut Frame, app: &App, area: Rect) { let focused = app.focus == Focus::Tasks; let today = today_local(); @@ -172,22 +204,33 @@ fn render_tasks(frame: &mut Frame, app: &App, area: Rect) { Style::default() }; let cursor = if selected { "▌" } else { " " }; + let recur = t.recurrence.is_some(); - // Pad the title so the chip sits at the right edge. + // Pad the title so the right side (↻ + date chip) aligns right. let chip_w = chip.len(); - let fixed = 1 + 2; // cursor + glyph + space - let avail = width.saturating_sub(fixed + chip_w + 1); + let recur_w = if recur { 2 } else { 0 }; // "↻ " + let fixed = 1 + 2 + 1; // cursor + "glyph " + trailing space + let avail = width.saturating_sub(fixed + recur_w + chip_w); let mut title: String = t.title.chars().take(avail).collect(); let pad = avail.saturating_sub(title.chars().count()); title.push_str(&" ".repeat(pad)); - ListItem::new(Line::from(vec![ + let mut header = vec![ Span::styled(cursor, Style::default().fg(Color::Cyan)), Span::styled(format!("{glyph} "), gstyle), Span::styled(title, title_style), Span::raw(" "), - Span::styled(chip, chip_style), - ])) + ]; + if recur { + header.push(Span::styled("↻ ", Style::default().fg(Color::Magenta))); + } + header.push(Span::styled(chip, chip_style)); + + let mut lines = vec![Line::from(header)]; + if selected { + lines.extend(task_detail_lines(app, t, today)); + } + ListItem::new(lines) }) .collect() }; diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 25e9d0c..60b4cf1 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -177,6 +177,38 @@ fn quick_add_captures_a_task_that_appears_in_the_view() { ); } +#[test] +fn recurring_task_shows_glyph_and_selected_detail_block() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + let proj = c + .call( + "node.create", + json!({ "kind": "project", "title": "Routines" }), + ) + .unwrap(); + c.call( + "task.create", + json!({ + "title": "Daily standup", + "attention": "red", + "recurrence": "FREQ=DAILY", + "project_id": proj["id"], + }), + ) + .unwrap(); + + let app = App::new(ClientBackend::new(client(&socket))).unwrap(); + let s = screen(&app); + // Recurrence glyph on the row... + assert!(s.contains('↻'), "recurrence glyph missing:\n{s}"); + // ...and the selected task's inline detail block (cursor starts on row 0). + assert!(s.contains("recurs:"), "no recurrence detail:\n{s}"); + assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}"); + assert!(s.contains("project:"), "no project detail:\n{s}"); + assert!(s.contains("Routines"), "project name missing:\n{s}"); +} + #[test] fn search_finds_a_matching_node_and_overlays_results() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 2ed1694..704e383 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -37,6 +37,7 @@ fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> Ranke do_date: None, late_on: None, state: TaskState::Outstanding, + recurrence: None, tombstoned: false, project_id: None, canonical_context_id: ctx.map(str::to_string),