feat(energy): Phase 3 — match-my-energy task sort + tri-state tag column
Closes Phase 3 of the 2026-04-23 feature-complete roadmap. Incorporates the Codex plan-review fixes from this session: profile-free index, tri- state update command, and de-prioritise-not-hide semantics. Storage (kon-storage): - Migration v11 adds `energy TEXT` to `tasks` with a CHECK constraint on `high | medium | brain_dead | NULL`. Index `(energy, created_at DESC)` — deliberately not per-profile because the tasks table carries no profile_id column yet (tracked as a separate gap in HANDOVER). - `TaskRow.energy: Option<String>` plus `task_row_from` read. - `insert_task` signature grows by one optional arg (`energy`). Allowed `too_many_arguments` with a rationale comment — the positional shape matches the column order and flipping to a params struct would have rippled through every caller for cosmetic benefit only. - New `set_task_energy(pool, id, Option<&str>) -> TaskRow`. Lives as its own function because `update_task` uses COALESCE to let `None` mean "preserve" — which would make clearing the tag impossible. - Two new tests: round-trip including explicit NULL clear, and CHECK constraint rejection of unknown values. - Tests updated for the v10 → v11 version bump. Tauri (src-tauri): - `TaskDto.energy`. `CreateTaskRequest.energy` (optional). Inline validation against the allowed set before hitting the DB, so frontend bugs surface as friendly errors instead of CHECK-constraint failures. - New `set_task_energy_cmd` command mirroring the storage tri-state API. Frontend (svelte): - `EnergyLevel` type added to `types/app.ts`. `TaskDto`, `TaskEntry`, and `TaskDraft` grow an `energy` field. - `SettingsState.currentEnergy` (persisted) + `matchMyEnergy` (persisted toggle). Defaults: null + false — no surface change until user opts in. - `setTaskEnergy(id, EnergyLevel | null)` action on the task store. Calls the dedicated Tauri command, updates local state, broadcasts to sibling windows. - `EnergyChip.svelte` — new component. Cycles unset → High → Medium → Brain-Dead → unset on click. Colour tokens: accent / warning / text-tertiary (deliberately not danger-red for Brain-Dead — the brief is explicit that this state must not feel pathologised). - Chip rendered on every task row in TasksPage and every row in WipTaskList. Hidden-until-hover when energy is unset so untagged rows stay calm; always visible once tagged because the colour is the signal. - Tasks page header gains a "I feel" segmented control and a "Match my energy" toggle. When both are active, matching tasks sort to the top — unset tasks are treated as Medium-equivalent. Nothing is ever hidden; this is a de-prioritisation, not a filter. Deferred / out of scope: - LLM-driven surfacing (brief says "The AI surfaces...") — deterministic client-side sort is v1; LLM layer is a later phase. - tasks.profile_id + per-profile energy sort — separate migration. All green: cargo build + 251 tests + clippy -D warnings (0 warnings) + fmt + svelte-check (0/0) + npm run build.
This commit is contained in:
@@ -270,6 +270,11 @@ pub async fn search_transcripts(
|
||||
/// Insert a task. `list_id` and `effort` are nullable (schema predates their
|
||||
/// UI surfacing); `notes` defaults to '' at the column level. Callers that
|
||||
/// want to set metadata at creation time pass `Some(...)`; omit for defaults.
|
||||
///
|
||||
/// Positional signature is deliberately flat — it mirrors the `tasks`
|
||||
/// schema columns one-to-one. Refactor to a params struct only if another
|
||||
/// nullable is added after `energy`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn insert_task(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
@@ -278,10 +283,11 @@ pub async fn insert_task(
|
||||
source_transcript_id: Option<&str>,
|
||||
list_id: Option<&str>,
|
||||
effort: Option<&str>,
|
||||
energy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort, energy) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(text)
|
||||
@@ -289,6 +295,7 @@ pub async fn insert_task(
|
||||
.bind(source_transcript_id)
|
||||
.bind(list_id)
|
||||
.bind(effort)
|
||||
.bind(energy)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?;
|
||||
@@ -298,7 +305,7 @@ pub async fn insert_task(
|
||||
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id \
|
||||
source_transcript_id, parent_task_id, energy \
|
||||
FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@@ -311,7 +318,7 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id FROM tasks WHERE id = ?",
|
||||
source_transcript_id, parent_task_id, energy FROM tasks WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
@@ -361,6 +368,27 @@ pub async fn update_task(
|
||||
})
|
||||
}
|
||||
|
||||
/// Dedicated tri-state energy setter. Exists as its own function because
|
||||
/// `update_task` uses `COALESCE(?, col)` to let `None` mean "preserve" —
|
||||
/// which makes it impossible to explicitly clear energy back to NULL.
|
||||
/// `set_task_energy` always writes exactly the value passed, including
|
||||
/// `None` to clear. Returns the refreshed row.
|
||||
///
|
||||
/// Caller is responsible for validating `energy` is one of the allowed
|
||||
/// values; the CHECK constraint will reject anything else at commit time.
|
||||
pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>) -> Result<TaskRow> {
|
||||
sqlx::query("UPDATE tasks SET energy = ? WHERE id = ?")
|
||||
.bind(energy)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("set_task_energy failed: {e}")))?;
|
||||
|
||||
get_task_by_id(pool, id).await?.ok_or_else(|| {
|
||||
KonError::StorageError(format!("set_task_energy: task {id} not found after update"))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn insert_subtask(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
@@ -380,7 +408,7 @@ pub async fn insert_subtask(
|
||||
pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id \
|
||||
source_transcript_id, parent_task_id, energy \
|
||||
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(parent_id)
|
||||
@@ -577,6 +605,11 @@ pub struct TaskRow {
|
||||
pub created_at: String,
|
||||
pub source_transcript_id: Option<String>,
|
||||
pub parent_task_id: Option<String>,
|
||||
/// Phase 3 energy tagging: one of `"high"`, `"medium"`, `"brain_dead"`,
|
||||
/// or `None`. Enforced at the DB layer via a CHECK constraint (see
|
||||
/// migration v11). Unset is the expected normal case — the match-my-
|
||||
/// energy sort treats unset as Medium-equivalent.
|
||||
pub energy: Option<String>,
|
||||
}
|
||||
|
||||
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||
@@ -639,6 +672,7 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
|
||||
created_at: r.get("created_at"),
|
||||
source_transcript_id: r.get("source_transcript_id"),
|
||||
parent_task_id: r.get("parent_task_id"),
|
||||
energy: r.get("energy"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,9 +1192,18 @@ mod tests {
|
||||
async fn task_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
insert_task(&pool, "task1", "Buy groceries", "today", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_task(
|
||||
&pool,
|
||||
"task1",
|
||||
"Buy groceries",
|
||||
"today",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = list_tasks(&pool).await.unwrap();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
@@ -1179,7 +1222,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn subtask_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "p1", "Write report", "inbox", None, None, None)
|
||||
insert_task(&pool, "p1", "Write report", "inbox", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s1", "Open document", "p1")
|
||||
@@ -1224,7 +1267,7 @@ mod tests {
|
||||
// child must also reopen the parent so "parent is done iff
|
||||
// every child is done" holds in both directions.
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
|
||||
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s1", "Final test", "p1")
|
||||
@@ -1258,10 +1301,10 @@ mod tests {
|
||||
// Sanity: tasks without a parent must not trigger the parent-
|
||||
// reopen branch (it should no-op cleanly).
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "a", "A", "inbox", None, None, None)
|
||||
insert_task(&pool, "a", "A", "inbox", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_task(&pool, "b", "B", "inbox", None, None, None)
|
||||
insert_task(&pool, "b", "B", "inbox", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
complete_task(&pool, "a").await.unwrap();
|
||||
@@ -1385,7 +1428,7 @@ mod tests {
|
||||
async fn update_task_overwrites_provided_fields() {
|
||||
// Task 2.6 — happy path: insert, update bucket + effort, read back.
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "u1", "Draft post", "inbox", None, None, None)
|
||||
insert_task(&pool, "u1", "Draft post", "inbox", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1402,9 +1445,18 @@ mod tests {
|
||||
async fn update_task_partial_leaves_others_unchanged() {
|
||||
// Task 2.6 — partial update: only notes changes; text/bucket intact.
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "u2", "Prep slides", "today", None, None, Some("30m"))
|
||||
.await
|
||||
.unwrap();
|
||||
insert_task(
|
||||
&pool,
|
||||
"u2",
|
||||
"Prep slides",
|
||||
"today",
|
||||
None,
|
||||
None,
|
||||
Some("30m"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = update_task(
|
||||
&pool,
|
||||
@@ -1433,6 +1485,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Energy tagging (Phase 3) ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_task_energy_round_trip_includes_explicit_clear() {
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "e1", "Write report", "inbox", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fresh task: energy must be NULL.
|
||||
let t = get_task_by_id(&pool, "e1").await.unwrap().unwrap();
|
||||
assert!(t.energy.is_none(), "new task must start with energy unset");
|
||||
|
||||
// Set High.
|
||||
let t = set_task_energy(&pool, "e1", Some("high")).await.unwrap();
|
||||
assert_eq!(t.energy.as_deref(), Some("high"));
|
||||
|
||||
// Change to Medium.
|
||||
let t = set_task_energy(&pool, "e1", Some("medium")).await.unwrap();
|
||||
assert_eq!(t.energy.as_deref(), Some("medium"));
|
||||
|
||||
// Explicit clear back to NULL — the whole reason this function
|
||||
// exists separately from update_task's COALESCE semantics.
|
||||
let t = set_task_energy(&pool, "e1", None).await.unwrap();
|
||||
assert!(
|
||||
t.energy.is_none(),
|
||||
"set_task_energy(None) must explicitly clear the column"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_task_energy_rejects_unknown_value_via_check_constraint() {
|
||||
// Migration v11 defines a CHECK constraint; invalid values must
|
||||
// be rejected at the DB layer even if a caller bypasses frontend
|
||||
// validation.
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "e2", "Task", "inbox", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let res = set_task_energy(&pool, "e2", Some("turbo")).await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"CHECK constraint must reject energy values outside the enum"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Profile CRUD tests (Task 11) ---
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user