generated from eblume/project-template
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:
parent
391277c939
commit
1833863594
7 changed files with 97 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")?,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue