feat(tui): recurring-task glyph + inline detail under the selected row (§8.1)

Recurring tasks now show a ↻ marker on their row, and the highlighted task
expands inline beneath itself with a dimmed detail block: project name,
recurrence rule, and do/late dates (only the fields that are set). Project
name resolves client-side from the sidebar; dates were already on the row.

Backend: RankedTask gains `recurrence: Option<String>` (populated in
ranked_from_row from t.recurrence; both list/next select lists updated) — the
only data the row was missing. Serializes over the socket automatically.

Tested: a real-daemon render test asserts the ↻ glyph plus the selected
detail block (recurs: FREQ=DAILY, project: Routines). 184 workspace tests;
clippy/fmt clean.

Note: the recurrence is shown as the raw RRULE for now (humanizing it is a
later polish). Subtask/checklist folding was dropped — those reference items
turned out to be blue backlog items, not sub-items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 08:08:16 -07:00
commit 1833863594
7 changed files with 97 additions and 8 deletions

View file

@ -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,

View file

@ -34,6 +34,8 @@ pub struct RankedTask {
pub late_on: Option<i64>,
/// Lifecycle state.
pub state: TaskState,
/// RRULE if this is a recurring task (surfaced for a `↻` marker + detail).
pub recurrence: Option<String>,
/// 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,

View file

@ -368,7 +368,7 @@ pub(super) fn list(
) -> Result<Vec<RankedTask>> {
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<Health> {
fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> {
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<RankedTask> {
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")?,

View file

@ -164,6 +164,14 @@ impl<B: Backend> App<B> {
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<String> {
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.

View file

@ -131,6 +131,38 @@ fn attention_style(a: Option<Attention>) -> (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<B: Backend>(
app: &App<B>,
t: &heph_core::RankedTask,
today: chrono::NaiveDate,
) -> Vec<Line<'static>> {
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<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let focused = app.focus == Focus::Tasks;
let today = today_local();
@ -172,22 +204,33 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, 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()
};

View file

@ -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();

View file

@ -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),