From 1d4f1070a22add49535ad8688b538452cb10d660 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 24 Apr 2026 14:53:19 +0100 Subject: [PATCH] =?UTF-8?q?feat(energy):=20Phase=203=20=E2=80=94=20match-m?= =?UTF-8?q?y-energy=20task=20sort=20+=20tri-state=20tag=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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. --- crates/storage/src/database.rs | 130 ++++++++++++++++++++++---- crates/storage/src/lib.rs | 8 +- crates/storage/src/migrations.rs | 27 +++++- src-tauri/src/commands/tasks.rs | 45 ++++++++- src-tauri/src/lib.rs | 1 + src/lib/components/EnergyChip.svelte | 106 +++++++++++++++++++++ src/lib/components/WipTaskList.svelte | 9 +- src/lib/pages/TasksPage.svelte | 93 +++++++++++++++++- src/lib/stores/page.svelte.ts | 28 ++++++ src/lib/types/app.ts | 25 +++++ 10 files changed, 445 insertions(+), 27 deletions(-) create mode 100644 src/lib/components/EnergyChip.svelte diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index ef44df4..2249b3f 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -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> { 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> { pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result> { 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 { + 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> { 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, pub parent_task_id: Option, + /// 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, } 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] diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 3fe1ac6..5ed569a 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -12,9 +12,9 @@ pub use database::{ get_profile, get_setting, get_task_by_id, get_transcript, init, insert_subtask, insert_task, insert_transcript, list_feedback_examples, list_profile_terms, list_profiles, list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged, - log_error, record_feedback, search_transcripts, set_setting, uncomplete_task, update_profile, - update_task, update_transcript, update_transcript_meta, ErrorLogRow, FeedbackRow, - FeedbackTargetType, InsertTranscriptParams, ProfileRow, ProfileTermRow, RecordFeedbackParams, - TaskRow, TranscriptRow, + log_error, record_feedback, search_transcripts, set_setting, set_task_energy, uncomplete_task, + update_profile, update_task, update_transcript, update_transcript_meta, ErrorLogRow, + FeedbackRow, FeedbackTargetType, InsertTranscriptParams, ProfileRow, ProfileTermRow, + RecordFeedbackParams, TaskRow, TranscriptRow, }; pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir}; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index b4bb4b6..6715c7d 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -377,6 +377,29 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ ON feedback(profile_id, target_type, created_at DESC); "#, ), + ( + 11, + "tasks: energy tagging for match-my-energy sort", + r#" + -- Phase 3 of the feature-complete roadmap: replaces the cut + -- temptation-bundling feature with a deterministic client-side + -- sort that matches tasks to the user's current energy state. + -- NULL is the expected normal case — users who never tag get + -- Medium-equivalent treatment at sort time (see Match-my-energy + -- logic in src/lib/pages/TasksPage.svelte). + -- + -- profile_id is deliberately absent from the index: tasks + -- currently carry no profile_id column, so a per-profile index + -- is out of scope until the broader task → profile migration + -- lands. See HANDOVER deferred list. + ALTER TABLE tasks + ADD COLUMN energy TEXT + CHECK (energy IS NULL OR energy IN ('high', 'medium', 'brain_dead')); + + CREATE INDEX idx_tasks_energy_created + ON tasks(energy, created_at DESC); + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -526,7 +549,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 10); + assert_eq!(count, 11); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -545,7 +568,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 10); + assert_eq!(count, 11); } #[tokio::test] diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs index dfd38cd..cb553fd 100644 --- a/src-tauri/src/commands/tasks.rs +++ b/src-tauri/src/commands/tasks.rs @@ -12,8 +12,9 @@ use kon_storage::{ delete_task as db_delete_task, get_task_by_id as db_get_task, insert_subtask as db_insert_subtask, insert_task as db_insert_task, list_feedback_examples as db_list_feedback_examples, list_subtasks as db_list_subtasks, - list_tasks as db_list_tasks, uncomplete_task as db_uncomplete_task, - update_task as db_update_task, FeedbackRow, FeedbackTargetType, TaskRow, + list_tasks as db_list_tasks, set_task_energy as db_set_task_energy, + uncomplete_task as db_uncomplete_task, update_task as db_update_task, FeedbackRow, + FeedbackTargetType, TaskRow, }; use crate::AppState; @@ -33,6 +34,7 @@ pub struct TaskDto { pub created_at: String, pub source_transcript_id: Option, pub parent_task_id: Option, + pub energy: Option, } impl From for TaskDto { @@ -49,10 +51,27 @@ impl From for TaskDto { created_at: r.created_at, source_transcript_id: r.source_transcript_id, parent_task_id: r.parent_task_id, + energy: r.energy, } } } +/// Accepted energy tag values. Kept as a const so frontend and storage +/// validate against the same list. Migration v11 enforces the same +/// set via a CHECK constraint. +const ENERGY_LEVELS: &[&str] = &["high", "medium", "brain_dead"]; + +fn validate_energy(raw: Option<&str>) -> Result, String> { + match raw { + None => Ok(None), + Some(s) if ENERGY_LEVELS.contains(&s) => Ok(Some(s)), + Some(other) => Err(format!( + "energy must be one of {:?} or null, got {:?}", + ENERGY_LEVELS, other + )), + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateTaskRequest { @@ -65,6 +84,8 @@ pub struct CreateTaskRequest { pub list_id: Option, #[serde(default)] pub effort: Option, + #[serde(default)] + pub energy: Option, } #[tauri::command] @@ -72,6 +93,7 @@ pub async fn create_task_cmd( state: tauri::State<'_, AppState>, request: CreateTaskRequest, ) -> Result { + let energy = validate_energy(request.energy.as_deref())?; db_insert_task( &state.db, &request.id, @@ -80,6 +102,7 @@ pub async fn create_task_cmd( request.source_transcript_id.as_deref(), request.list_id.as_deref(), request.effort.as_deref(), + energy, ) .await .map_err(|e| e.to_string())?; @@ -168,6 +191,24 @@ pub async fn uncomplete_task_cmd( .map_err(|e| e.to_string()) } +/// Phase 3: set or clear the `energy` tag on a task. Dedicated command +/// rather than a field on `update_task_cmd` because the existing update +/// path uses `COALESCE` semantics where `None` means "preserve" — which +/// makes clearing the tag impossible. This command always writes exactly +/// what you send, including `None` to explicitly clear. +#[tauri::command] +pub async fn set_task_energy_cmd( + state: tauri::State<'_, AppState>, + id: String, + energy: Option, +) -> Result { + let validated = validate_energy(energy.as_deref())?; + let row = db_set_task_energy(&state.db, &id, validated) + .await + .map_err(|e| e.to_string())?; + Ok(TaskDto::from(row)) +} + /// Convert HITL feedback rows fetched from storage into the few-shot /// exemplar shape the LLM crate consumes. We reconstruct the `input` /// (parent task text, transcript chunk) from `context_json` where the diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f85ea67..41f6742 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -284,6 +284,7 @@ pub fn run() { commands::tasks::complete_task_cmd, commands::tasks::delete_task_cmd, commands::tasks::uncomplete_task_cmd, + commands::tasks::set_task_energy_cmd, commands::tasks::decompose_and_store, commands::tasks::extract_tasks_from_transcript_cmd, commands::tasks::list_subtasks_cmd, diff --git a/src/lib/components/EnergyChip.svelte b/src/lib/components/EnergyChip.svelte new file mode 100644 index 0000000..2bda1ad --- /dev/null +++ b/src/lib/components/EnergyChip.svelte @@ -0,0 +1,106 @@ + + + + + diff --git a/src/lib/components/WipTaskList.svelte b/src/lib/components/WipTaskList.svelte index 8a226f2..b20379d 100644 --- a/src/lib/components/WipTaskList.svelte +++ b/src/lib/components/WipTaskList.svelte @@ -1,6 +1,7 @@