generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Failing after 3s
Slice 4 — the flagship Tactical blank-slate engine. Pure and clock-injected, two stages: - Candidacy filter: committed ∧ outstanding ∧ ¬tombstoned ∧ ≠blue ∧ actionable (do_date NULL or ≤ now) ∧ in scope. do_date is used ONLY here — a boolean "can I do this now?" gate, never urgency. - Order: an ordered list of named Dimensions applied lexicographically (PastLateOn → LateOverdueAmount → Attention band → CreatedAt FIFO), with node_id as final tiebreak for a total order. Reorder RANKING in one place to retune. late_on is the sole urgency signal (global tier); age never becomes urgency. blue hidden; red always shown past limit. Storage `Store::next` loads candidates via a SQL join (project + canonical-context links) and runs the pure engine with the store clock. 13 table-driven unit cases + 3 proptests (antisymmetry, sorted output fully ordered, equality ⇒ identity) + 2 end-to-end. 38 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
6.2 KiB
Rust
189 lines
6.2 KiB
Rust
//! `tasks` table operations.
|
|
//!
|
|
//! A committed task is a `task` node plus a `tasks` row. On creation it also
|
|
//! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6).
|
|
|
|
use rusqlite::{Connection, OptionalExtension, Row};
|
|
|
|
use super::{links, nodes};
|
|
use crate::error::{Error, Result};
|
|
use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState};
|
|
use crate::ranking::{self, RankedTask};
|
|
|
|
fn from_row(row: &Row) -> rusqlite::Result<Task> {
|
|
let attention = match row.get::<_, Option<String>>("attention")? {
|
|
Some(s) => Some(
|
|
Attention::parse(&s)
|
|
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
|
|
),
|
|
None => None,
|
|
};
|
|
Ok(Task {
|
|
node_id: row.get("node_id")?,
|
|
attention,
|
|
do_date: row.get("do_date")?,
|
|
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")?,
|
|
})
|
|
}
|
|
|
|
const COLUMNS: &str = "node_id, attention, do_date, late_on, state, recurrence";
|
|
|
|
/// Create a committed task: its task node, the `tasks` row, the canonical
|
|
/// context doc, the `canonical-context` link, and (if given) an `in-project`
|
|
/// link — all in one transaction.
|
|
pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTask) -> Result<Task> {
|
|
let task = Task {
|
|
node_id: String::new(), // filled below
|
|
attention: input.attention,
|
|
do_date: input.do_date,
|
|
late_on: input.late_on,
|
|
state: TaskState::Outstanding,
|
|
recurrence: input.recurrence,
|
|
};
|
|
|
|
let tx = conn.transaction()?;
|
|
|
|
let task_node = nodes::build(owner, now, NodeKind::Task, input.title.clone(), None);
|
|
nodes::insert(&tx, &task_node)?;
|
|
tx.execute(
|
|
"INSERT INTO tasks (node_id, attention, do_date, late_on, state, recurrence)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
(
|
|
&task_node.id,
|
|
task.attention.map(|a| a.as_str()),
|
|
task.do_date,
|
|
task.late_on,
|
|
task.state.as_str(),
|
|
&task.recurrence,
|
|
),
|
|
)?;
|
|
|
|
// The canonical context doc (the task's jumping-off point / checklist body).
|
|
let doc = nodes::build(
|
|
owner,
|
|
now,
|
|
NodeKind::Doc,
|
|
input.title.clone(),
|
|
Some(String::new()),
|
|
);
|
|
nodes::insert(&tx, &doc)?;
|
|
links::add(&tx, now, &task_node.id, &doc.id, LinkType::CanonicalContext)?;
|
|
|
|
if let Some(project_id) = &input.project_id {
|
|
links::add(&tx, now, &task_node.id, project_id, LinkType::InProject)?;
|
|
}
|
|
|
|
tx.commit()?;
|
|
|
|
Ok(Task {
|
|
node_id: task_node.id,
|
|
..task
|
|
})
|
|
}
|
|
|
|
/// Fetch a task by node id.
|
|
pub(super) fn get(conn: &Connection, node_id: &str) -> Result<Option<Task>> {
|
|
let task = conn
|
|
.query_row(
|
|
&format!("SELECT {COLUMNS} FROM tasks WHERE node_id = ?1"),
|
|
[node_id],
|
|
from_row,
|
|
)
|
|
.optional()?;
|
|
Ok(task)
|
|
}
|
|
|
|
fn require(conn: &Connection, node_id: &str) -> Result<Task> {
|
|
get(conn, node_id)?.ok_or_else(|| Error::NodeNotFound(node_id.to_string()))
|
|
}
|
|
|
|
/// Set a task's lifecycle state.
|
|
pub(super) fn set_state(
|
|
conn: &Connection,
|
|
now: i64,
|
|
node_id: &str,
|
|
state: TaskState,
|
|
) -> Result<Task> {
|
|
let updated = conn.execute(
|
|
"UPDATE tasks SET state = ?1 WHERE node_id = ?2",
|
|
(state.as_str(), node_id),
|
|
)?;
|
|
if updated == 0 {
|
|
return Err(Error::NodeNotFound(node_id.to_string()));
|
|
}
|
|
nodes::touch(conn, now, node_id)?;
|
|
require(conn, node_id)
|
|
}
|
|
|
|
/// The Tactical "what is next?" ranking for `owner` at `now` (tech-spec §7).
|
|
pub(super) fn next(
|
|
conn: &Connection,
|
|
owner: &str,
|
|
now: i64,
|
|
scope: Option<&str>,
|
|
limit: usize,
|
|
) -> Result<Vec<RankedTask>> {
|
|
let candidates = load_candidates(conn, owner)?;
|
|
Ok(ranking::rank(candidates, now, scope, limit))
|
|
}
|
|
|
|
/// Load every non-tombstoned committed task for `owner` as a ranking candidate,
|
|
/// joining in its project and canonical-context link targets.
|
|
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,
|
|
(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,
|
|
(SELECT dst_id FROM links
|
|
WHERE src_id = n.id AND type = 'canonical-context' AND tombstoned = 0
|
|
ORDER BY created_at, id LIMIT 1) AS ctx_id
|
|
FROM tasks t JOIN nodes n ON n.id = t.node_id
|
|
WHERE n.owner_id = ?1 AND n.tombstoned = 0";
|
|
let mut stmt = conn.prepare(sql)?;
|
|
let rows = stmt.query_map([owner], |row| {
|
|
let attention = match row.get::<_, Option<String>>("attention")? {
|
|
Some(s) => Some(
|
|
Attention::parse(&s)
|
|
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
|
|
),
|
|
None => None,
|
|
};
|
|
Ok(RankedTask {
|
|
node_id: row.get("id")?,
|
|
title: row.get("title")?,
|
|
attention,
|
|
do_date: row.get("do_date")?,
|
|
late_on: row.get("late_on")?,
|
|
state: TaskState::parse(&row.get::<_, String>("state")?)
|
|
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
|
|
tombstoned: row.get::<_, i64>("tombstoned")? != 0,
|
|
project_id: row.get("project_id")?,
|
|
canonical_context_id: row.get("ctx_id")?,
|
|
created_at: row.get("created_at")?,
|
|
})
|
|
})?;
|
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
|
}
|
|
|
|
/// Set a task's attention-state.
|
|
pub(super) fn set_attention(
|
|
conn: &Connection,
|
|
now: i64,
|
|
node_id: &str,
|
|
attention: Attention,
|
|
) -> Result<Task> {
|
|
let updated = conn.execute(
|
|
"UPDATE tasks SET attention = ?1 WHERE node_id = ?2",
|
|
(attention.as_str(), node_id),
|
|
)?;
|
|
if updated == 0 {
|
|
return Err(Error::NodeNotFound(node_id.to_string()));
|
|
}
|
|
nodes::touch(conn, now, node_id)?;
|
|
require(conn, node_id)
|
|
}
|