diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a978f..ee20ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Widget CRUD: new focus, delete focus, add and remove tasks. - `settings.yaml` with focus and task caps; cap badge and macOS overload notifications. - Edit-proposal modal, empty-state hero, and v1 README. + +### Changed + +- Cap notifications now flow through a `CapNotifier` trait owned by the commands crate; the Tauri shell provides a single adapter, removing the inline cap-evaluation/notification logic from the app composition root. +- Proposal accept/reject now lives in a dedicated `ProposalLifecycle` module that owns proposal load → edit → validate → mutate → record-decision → clear-queue. The `ProposalDispatcher`/`*Applier` strategy traits are removed; the inline `match` over `ProposalKind` replaces the per-kind adapters. Focus creation in the direct path and the proposal path now share a single helper. +- Tauri filesystem watchers now share a single `install_change_handlers` helper that fans a debounced change event out to a list of handlers, replacing the asymmetric mix of inline closure and per-watcher helper. +- React widget composes its three async data sources through a single `useAppState` hook with one readiness contract; proposal-reader errors now surface in the UI instead of being silently swallowed. diff --git a/crates/commands/src/applier.rs b/crates/commands/src/applier.rs deleted file mode 100644 index 2cd7681..0000000 --- a/crates/commands/src/applier.rs +++ /dev/null @@ -1,213 +0,0 @@ -use std::sync::Arc; - -use adhd_ranch_domain::{Proposal, ProposalKind}; -use adhd_ranch_storage::FocusStore; - -use crate::error::CommandError; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AppliedOutcome { - pub target: Option, -} - -pub trait ProposalApplier: Send + Sync { - fn apply(&self, proposal: &Proposal) -> Result; -} - -pub struct AddTaskApplier { - store: Arc, -} - -impl AddTaskApplier { - pub fn new(store: Arc) -> Self { - Self { store } - } -} - -impl ProposalApplier for AddTaskApplier { - fn apply(&self, proposal: &Proposal) -> Result { - match &proposal.kind { - ProposalKind::AddTask { - target_focus_id, - task_text, - } => { - self.store.append_task(target_focus_id, task_text)?; - Ok(AppliedOutcome { - target: Some(target_focus_id.clone()), - }) - } - _ => unreachable!("AddTaskApplier called with non-add_task kind"), - } - } -} - -pub struct NewFocusApplier { - store: Arc, - clock: Arc String + Send + Sync>, - id_gen: Arc String + Send + Sync>, -} - -impl NewFocusApplier { - pub fn new( - store: Arc, - clock: Arc String + Send + Sync>, - id_gen: Arc String + Send + Sync>, - ) -> Self { - Self { - store, - clock, - id_gen, - } - } -} - -impl ProposalApplier for NewFocusApplier { - fn apply(&self, proposal: &Proposal) -> Result { - match &proposal.kind { - ProposalKind::NewFocus { new_focus } => { - let id = (self.id_gen)(); - let created_at = (self.clock)(); - let slug = self.store.create_focus(new_focus, &id, &created_at)?; - Ok(AppliedOutcome { target: Some(slug) }) - } - _ => unreachable!("NewFocusApplier called with non-new_focus kind"), - } - } -} - -pub struct DiscardApplier; - -impl ProposalApplier for DiscardApplier { - fn apply(&self, _proposal: &Proposal) -> Result { - Ok(AppliedOutcome { target: None }) - } -} - -pub struct ProposalDispatcher { - add_task: Arc, - new_focus: Arc, - discard: Arc, -} - -impl ProposalDispatcher { - pub fn new( - add_task: Arc, - new_focus: Arc, - discard: Arc, - ) -> Self { - Self { - add_task, - new_focus, - discard, - } - } - - pub fn from_store( - store: Arc, - clock: Arc String + Send + Sync>, - id_gen: Arc String + Send + Sync>, - ) -> Self { - Self::new( - Arc::new(AddTaskApplier::new(store.clone())), - Arc::new(NewFocusApplier::new(store, clock, id_gen)), - Arc::new(DiscardApplier), - ) - } - - pub fn apply(&self, proposal: &Proposal) -> Result { - let strategy = self.strategy_for(&proposal.kind); - strategy.apply(proposal) - } - - fn strategy_for(&self, kind: &ProposalKind) -> &Arc { - match kind { - ProposalKind::AddTask { .. } => &self.add_task, - ProposalKind::NewFocus { .. } => &self.new_focus, - ProposalKind::Discard => &self.discard, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use adhd_ranch_domain::{NewFocus, ProposalId, ProposalKind}; - use adhd_ranch_storage::MarkdownFocusStore; - use std::fs; - use tempfile::TempDir; - - fn write_focus(root: &std::path::Path, slug: &str, body: &str) { - let dir = root.join(slug); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("focus.md"), body).unwrap(); - } - - fn focus_md(id: &str) -> String { - format!("---\nid: {id}\ntitle: A\ndescription:\ncreated_at: 2026-04-30T12:00:00Z\n---\n") - } - - fn dispatcher(focuses_root: &std::path::Path) -> ProposalDispatcher { - let store: Arc = Arc::new(MarkdownFocusStore::new(focuses_root)); - ProposalDispatcher::from_store( - store, - Arc::new(|| "2026-04-30T12:00:00Z".to_string()), - Arc::new(|| "id-fixed".to_string()), - ) - } - - fn proposal(id: &str, kind: ProposalKind) -> Proposal { - Proposal { - id: ProposalId(id.into()), - kind, - summary: "s".into(), - reasoning: "r".into(), - created_at: "2026-04-30T12:00:00Z".into(), - } - } - - #[test] - fn dispatch_add_task_appends_bullet_and_returns_focus_id() { - let dir = TempDir::new().unwrap(); - write_focus(dir.path(), "f1", &focus_md("f1")); - let d = dispatcher(dir.path()); - let outcome = d - .apply(&proposal( - "p1", - ProposalKind::AddTask { - target_focus_id: "f1".into(), - task_text: "ship it".into(), - }, - )) - .unwrap(); - assert_eq!(outcome.target.as_deref(), Some("f1")); - let content = fs::read_to_string(dir.path().join("f1/focus.md")).unwrap(); - assert!(content.contains("- [ ] ship it")); - } - - #[test] - fn dispatch_new_focus_creates_dir_and_returns_slug() { - let dir = TempDir::new().unwrap(); - let d = dispatcher(dir.path()); - let outcome = d - .apply(&proposal( - "p1", - ProposalKind::NewFocus { - new_focus: NewFocus { - title: "Customer X bug".into(), - description: "ship".into(), - }, - }, - )) - .unwrap(); - assert_eq!(outcome.target.as_deref(), Some("customer-x-bug")); - assert!(dir.path().join("customer-x-bug/focus.md").exists()); - } - - #[test] - fn dispatch_discard_is_noop_with_no_target() { - let dir = TempDir::new().unwrap(); - let d = dispatcher(dir.path()); - let outcome = d.apply(&proposal("p1", ProposalKind::Discard)).unwrap(); - assert_eq!(outcome.target, None); - } -} diff --git a/crates/commands/src/caps.rs b/crates/commands/src/caps.rs new file mode 100644 index 0000000..23eacff --- /dev/null +++ b/crates/commands/src/caps.rs @@ -0,0 +1,263 @@ +use std::sync::Arc; + +use adhd_ranch_domain::{cap_state, OverCapMonitor, Settings}; +use adhd_ranch_storage::FocusStore; + +use crate::error::CommandError; + +pub trait CapNotifier: Send + Sync { + fn focuses_over_cap(&self, max: usize); + fn focuses_under_cap(&self); + fn task_over_cap(&self, focus_id: &str, max: usize); + fn task_under_cap(&self, focus_id: &str); +} + +pub struct CapEvaluator { + store: Arc, + monitor: Arc, + notifier: Arc, + settings: Settings, +} + +impl CapEvaluator { + pub fn new( + store: Arc, + monitor: Arc, + notifier: Arc, + settings: Settings, + ) -> Self { + Self { + store, + monitor, + notifier, + settings, + } + } + + pub fn evaluate(&self) -> Result<(), CommandError> { + let focuses = self.store.list()?; + let state = cap_state(&focuses, self.settings.caps); + let transition = self.monitor.evaluate(&state); + + if !self.settings.alerts.system_notifications { + return Ok(()); + } + + if transition.focuses_to_over { + self.notifier + .focuses_over_cap(self.settings.caps.max_focuses); + } + if transition.focuses_to_under { + self.notifier.focuses_under_cap(); + } + for id in &transition.task_to_over_focus_ids { + self.notifier + .task_over_cap(id, self.settings.caps.max_tasks_per_focus); + } + for id in &transition.task_to_under_focus_ids { + self.notifier.task_under_cap(id); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use adhd_ranch_domain::focus::{Focus, FocusId, Task}; + use adhd_ranch_domain::{Alerts, Caps, NewFocus}; + use adhd_ranch_storage::FocusStoreError; + + use super::*; + + #[derive(Debug, PartialEq, Eq)] + enum Call { + FocusesOver(usize), + FocusesUnder, + TaskOver(String, usize), + TaskUnder(String), + } + + struct RecordingNotifier { + calls: Mutex>, + } + + impl RecordingNotifier { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + } + } + + fn calls(&self) -> Vec { + self.calls.lock().unwrap().drain(..).collect() + } + } + + impl CapNotifier for RecordingNotifier { + fn focuses_over_cap(&self, max: usize) { + self.calls.lock().unwrap().push(Call::FocusesOver(max)); + } + fn focuses_under_cap(&self) { + self.calls.lock().unwrap().push(Call::FocusesUnder); + } + fn task_over_cap(&self, focus_id: &str, max: usize) { + self.calls + .lock() + .unwrap() + .push(Call::TaskOver(focus_id.to_string(), max)); + } + fn task_under_cap(&self, focus_id: &str) { + self.calls + .lock() + .unwrap() + .push(Call::TaskUnder(focus_id.to_string())); + } + } + + struct StubStore { + focuses: Mutex>, + } + + impl StubStore { + fn new() -> Self { + Self { + focuses: Mutex::new(Vec::new()), + } + } + + fn set(&self, focuses: Vec) { + *self.focuses.lock().unwrap() = focuses; + } + } + + impl FocusStore for StubStore { + fn list(&self) -> Result, FocusStoreError> { + Ok(self.focuses.lock().unwrap().clone()) + } + fn create_focus( + &self, + _new_focus: &NewFocus, + _id: &str, + _created_at: &str, + ) -> Result { + unimplemented!() + } + fn delete_focus(&self, _focus_id: &str) -> Result<(), FocusStoreError> { + unimplemented!() + } + fn append_task(&self, _focus_id: &str, _text: &str) -> Result<(), FocusStoreError> { + unimplemented!() + } + fn delete_task(&self, _focus_id: &str, _index: usize) -> Result<(), FocusStoreError> { + unimplemented!() + } + } + + fn focus_with_tasks(id: &str, count: usize) -> Focus { + Focus { + id: FocusId(id.into()), + title: id.into(), + description: String::new(), + created_at: String::new(), + tasks: (0..count) + .map(|i| Task { + id: format!("{id}:{i}"), + text: format!("t{i}"), + }) + .collect(), + } + } + + fn settings(notifications: bool) -> Settings { + Settings { + caps: Caps { + max_focuses: 5, + max_tasks_per_focus: 7, + }, + alerts: Alerts { + system_notifications: notifications, + }, + } + } + + fn build(notifications: bool) -> (Arc, Arc, CapEvaluator) { + let store = Arc::new(StubStore::new()); + let notifier = Arc::new(RecordingNotifier::new()); + let evaluator = CapEvaluator::new( + store.clone(), + Arc::new(OverCapMonitor::new()), + notifier.clone(), + settings(notifications), + ); + (store, notifier, evaluator) + } + + #[test] + fn under_caps_emits_nothing() { + let (store, notifier, evaluator) = build(true); + store.set(vec![focus_with_tasks("a", 3)]); + evaluator.evaluate().unwrap(); + assert!(notifier.calls().is_empty()); + } + + #[test] + fn focuses_to_over_calls_notifier_once() { + let (store, notifier, evaluator) = build(true); + store.set( + (0..6) + .map(|i| focus_with_tasks(&format!("f{i}"), 0)) + .collect(), + ); + evaluator.evaluate().unwrap(); + assert_eq!(notifier.calls(), vec![Call::FocusesOver(5)]); + + evaluator.evaluate().unwrap(); + assert!(notifier.calls().is_empty()); + } + + #[test] + fn focuses_recovery_calls_under() { + let (store, notifier, evaluator) = build(true); + store.set( + (0..6) + .map(|i| focus_with_tasks(&format!("f{i}"), 0)) + .collect(), + ); + evaluator.evaluate().unwrap(); + let _ = notifier.calls(); + + store.set( + (0..3) + .map(|i| focus_with_tasks(&format!("f{i}"), 0)) + .collect(), + ); + evaluator.evaluate().unwrap(); + assert_eq!(notifier.calls(), vec![Call::FocusesUnder]); + } + + #[test] + fn task_cap_transitions_per_focus() { + let (store, notifier, evaluator) = build(true); + store.set(vec![focus_with_tasks("a", 9)]); + evaluator.evaluate().unwrap(); + assert_eq!(notifier.calls(), vec![Call::TaskOver("a".into(), 7)]); + + store.set(vec![focus_with_tasks("a", 3)]); + evaluator.evaluate().unwrap(); + assert_eq!(notifier.calls(), vec![Call::TaskUnder("a".into())]); + } + + #[test] + fn notifications_disabled_suppresses_calls() { + let (store, notifier, evaluator) = build(false); + store.set( + (0..6) + .map(|i| focus_with_tasks(&format!("f{i}"), 0)) + .collect(), + ); + evaluator.evaluate().unwrap(); + assert!(notifier.calls().is_empty()); + } +} diff --git a/crates/commands/src/focus.rs b/crates/commands/src/focus.rs index 9056553..c0e812a 100644 --- a/crates/commands/src/focus.rs +++ b/crates/commands/src/focus.rs @@ -1,8 +1,11 @@ +use std::sync::Arc; + use adhd_ranch_domain::{Caps, Focus, NewFocus}; +use adhd_ranch_storage::FocusStore; use serde::{Deserialize, Serialize}; use crate::error::CommandError; -use crate::Commands; +use crate::{Clock, Commands, IdGen}; #[derive(Debug, Clone, Deserialize)] pub struct CreateFocusInput { @@ -16,6 +19,17 @@ pub struct CreatedFocus { pub id: String, } +pub(crate) fn create_focus_in_store( + store: &Arc, + clock: &Clock, + id_gen: &IdGen, + new_focus: &NewFocus, +) -> Result { + let id = id_gen(); + let created_at = clock(); + Ok(store.create_focus(new_focus, &id, &created_at)?) +} + impl Commands { pub fn list_focuses(&self) -> Result, CommandError> { Ok(self.store.list()?) @@ -25,13 +39,11 @@ impl Commands { if input.title.trim().is_empty() { return Err(CommandError::BadRequest("title must not be empty".into())); } - let id = (self.id_gen)(); - let created_at = (self.clock)(); let new_focus = NewFocus { title: input.title, description: input.description, }; - let slug = self.store.create_focus(&new_focus, &id, &created_at)?; + let slug = create_focus_in_store(&self.store, &self.clock, &self.id_gen, &new_focus)?; Ok(CreatedFocus { id: slug }) } diff --git a/crates/commands/src/lib.rs b/crates/commands/src/lib.rs index 147e1b4..0fe4d93 100644 --- a/crates/commands/src/lib.rs +++ b/crates/commands/src/lib.rs @@ -3,17 +3,16 @@ use std::sync::Arc; use adhd_ranch_domain::Settings; use adhd_ranch_storage::{DecisionLog, FocusStore, ProposalQueue}; -pub mod applier; +pub mod caps; pub mod error; pub mod focus; +pub mod lifecycle; pub mod proposal; -pub use applier::{ - AddTaskApplier, AppliedOutcome, DiscardApplier, NewFocusApplier, ProposalApplier, - ProposalDispatcher, -}; +pub use caps::{CapEvaluator, CapNotifier}; pub use error::CommandError; pub use focus::{CreateFocusInput, CreatedFocus}; +pub use lifecycle::ProposalLifecycle; pub use proposal::{CreateProposalInput, CreatedProposal, DecisionOutcome, ProposalEdit}; pub type Clock = Arc String + Send + Sync>; @@ -22,8 +21,7 @@ pub type IdGen = Arc String + Send + Sync>; pub struct Commands { pub(crate) store: Arc, pub(crate) queue: Arc, - pub(crate) decisions: Arc, - pub(crate) dispatcher: Arc, + pub(crate) lifecycle: Arc, pub(crate) clock: Clock, pub(crate) id_gen: IdGen, pub(crate) settings: Settings, @@ -34,16 +32,21 @@ impl Commands { store: Arc, queue: Arc, decisions: Arc, - dispatcher: Arc, clock: Clock, id_gen: IdGen, settings: Settings, ) -> Self { + let lifecycle = Arc::new(ProposalLifecycle::new( + store.clone(), + queue.clone(), + decisions, + clock.clone(), + id_gen.clone(), + )); Self { store, queue, - decisions, - dispatcher, + lifecycle, clock, id_gen, settings, diff --git a/crates/commands/src/lifecycle.rs b/crates/commands/src/lifecycle.rs new file mode 100644 index 0000000..41263b0 --- /dev/null +++ b/crates/commands/src/lifecycle.rs @@ -0,0 +1,306 @@ +use std::sync::Arc; + +use adhd_ranch_domain::{Decision, DecisionKind, Proposal, ProposalId, ProposalKind}; +use adhd_ranch_storage::{DecisionLog, FocusStore, ProposalQueue}; + +use crate::error::CommandError; +use crate::focus::create_focus_in_store; +use crate::proposal::{DecisionOutcome, ProposalEdit}; +use crate::{Clock, IdGen}; + +pub struct ProposalLifecycle { + store: Arc, + queue: Arc, + decisions: Arc, + clock: Clock, + id_gen: IdGen, +} + +impl ProposalLifecycle { + pub fn new( + store: Arc, + queue: Arc, + decisions: Arc, + clock: Clock, + id_gen: IdGen, + ) -> Self { + Self { + store, + queue, + decisions, + clock, + id_gen, + } + } + + pub fn accept(&self, id: &str, edit: ProposalEdit) -> Result { + let original = self.load(id)?; + let (proposal, edited) = apply_edit(original, &edit); + proposal.validate()?; + let target = self.apply(&proposal)?; + self.record_decision(&proposal, DecisionKind::Accept, target.clone(), edited)?; + self.queue.remove(&proposal.id)?; + Ok(DecisionOutcome { + id: id.to_string(), + target, + }) + } + + pub fn reject(&self, id: &str) -> Result { + let proposal = self.load(id)?; + self.record_decision(&proposal, DecisionKind::Reject, None, false)?; + self.queue.remove(&proposal.id)?; + Ok(DecisionOutcome { + id: id.to_string(), + target: None, + }) + } + + fn apply(&self, proposal: &Proposal) -> Result, CommandError> { + match &proposal.kind { + ProposalKind::AddTask { + target_focus_id, + task_text, + } => { + self.store.append_task(target_focus_id, task_text)?; + Ok(Some(target_focus_id.clone())) + } + ProposalKind::NewFocus { new_focus } => { + let slug = + create_focus_in_store(&self.store, &self.clock, &self.id_gen, new_focus)?; + Ok(Some(slug)) + } + ProposalKind::Discard => Ok(None), + } + } + + fn load(&self, id: &str) -> Result { + self.queue + .find(&ProposalId(id.to_string()))? + .ok_or_else(|| CommandError::NotFound(format!("proposal not found: {id}"))) + } + + fn record_decision( + &self, + proposal: &Proposal, + kind: DecisionKind, + target: Option, + edited: bool, + ) -> Result<(), CommandError> { + let decision = Decision { + ts: (self.clock)(), + proposal_id: proposal.id.0.clone(), + decision: kind, + reasoning: proposal.reasoning.clone(), + target, + edited, + }; + self.decisions.append(&decision)?; + Ok(()) + } +} + +fn apply_edit(mut proposal: Proposal, edit: &ProposalEdit) -> (Proposal, bool) { + let mut edited = false; + match &mut proposal.kind { + ProposalKind::AddTask { + target_focus_id, + task_text, + } => { + if let Some(new_id) = edit.target_focus_id.as_ref() { + if new_id != target_focus_id { + *target_focus_id = new_id.clone(); + edited = true; + } + } + if let Some(new_text) = edit.task_text.as_ref() { + if new_text != task_text { + *task_text = new_text.clone(); + edited = true; + } + } + } + ProposalKind::NewFocus { new_focus } => { + if let Some(replacement) = edit.new_focus.as_ref() { + if replacement != new_focus { + *new_focus = replacement.clone(); + edited = true; + } + } + } + ProposalKind::Discard => {} + } + (proposal, edited) +} + +#[cfg(test)] +mod tests { + use super::*; + use adhd_ranch_domain::{NewFocus, ProposalId, ProposalKind}; + use adhd_ranch_storage::{JsonlDecisionLog, JsonlProposalQueue, MarkdownFocusStore}; + use std::fs; + use tempfile::TempDir; + + struct Harness { + _dir: TempDir, + focuses_root: std::path::PathBuf, + lifecycle: ProposalLifecycle, + queue: Arc, + decisions_path: std::path::PathBuf, + } + + fn build_lifecycle() -> Harness { + let dir = TempDir::new().unwrap(); + let focuses_root = dir.path().join("focuses"); + fs::create_dir_all(&focuses_root).unwrap(); + let proposals_path = dir.path().join("proposals.jsonl"); + let decisions_path = dir.path().join("decisions.jsonl"); + + let store: Arc = Arc::new(MarkdownFocusStore::new(focuses_root.clone())); + let queue: Arc = Arc::new(JsonlProposalQueue::new(proposals_path)); + let decisions: Arc = + Arc::new(JsonlDecisionLog::new(decisions_path.clone())); + let clock: Clock = Arc::new(|| "2026-04-30T12:00:00Z".to_string()); + let id_gen: IdGen = Arc::new(|| "id-fixed".to_string()); + + let lifecycle = ProposalLifecycle::new(store, queue.clone(), decisions, clock, id_gen); + Harness { + _dir: dir, + focuses_root, + lifecycle, + queue, + decisions_path, + } + } + + fn write_focus(root: &std::path::Path, slug: &str, body: &str) { + let dir = root.join(slug); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("focus.md"), body).unwrap(); + } + + fn focus_md(id: &str) -> String { + format!("---\nid: {id}\ntitle: A\ndescription:\ncreated_at: 2026-04-30T12:00:00Z\n---\n") + } + + fn proposal(id: &str, kind: ProposalKind) -> Proposal { + Proposal { + id: ProposalId(id.into()), + kind, + summary: "s".into(), + reasoning: "r".into(), + created_at: "2026-04-30T12:00:00Z".into(), + } + } + + #[test] + fn accept_add_task_appends_bullet_records_decision_and_clears_queue() { + let h = build_lifecycle(); + write_focus(&h.focuses_root, "f1", &focus_md("f1")); + h.queue + .append(&proposal( + "p1", + ProposalKind::AddTask { + target_focus_id: "f1".into(), + task_text: "ship it".into(), + }, + )) + .unwrap(); + + let out = h.lifecycle.accept("p1", ProposalEdit::default()).unwrap(); + assert_eq!(out.target.as_deref(), Some("f1")); + + let body = fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap(); + assert!(body.contains("- [ ] ship it")); + assert!(h.queue.list().unwrap().is_empty()); + + let log = fs::read_to_string(&h.decisions_path).unwrap(); + assert!(log.contains("\"decision\":\"accept\"")); + assert!(log.contains("\"proposal_id\":\"p1\"")); + } + + #[test] + fn accept_new_focus_creates_dir_and_returns_slug() { + let h = build_lifecycle(); + h.queue + .append(&proposal( + "p1", + ProposalKind::NewFocus { + new_focus: NewFocus { + title: "Customer X bug".into(), + description: "ship".into(), + }, + }, + )) + .unwrap(); + + let out = h.lifecycle.accept("p1", ProposalEdit::default()).unwrap(); + assert_eq!(out.target.as_deref(), Some("customer-x-bug")); + assert!(h.focuses_root.join("customer-x-bug/focus.md").exists()); + } + + #[test] + fn accept_discard_records_decision_with_no_target() { + let h = build_lifecycle(); + h.queue + .append(&proposal("p1", ProposalKind::Discard)) + .unwrap(); + let out = h.lifecycle.accept("p1", ProposalEdit::default()).unwrap(); + assert_eq!(out.target, None); + assert!(h.queue.list().unwrap().is_empty()); + } + + #[test] + fn accept_with_edit_uses_overrides_and_marks_edited() { + let h = build_lifecycle(); + write_focus(&h.focuses_root, "f1", &focus_md("f1")); + write_focus(&h.focuses_root, "f2", &focus_md("f2")); + h.queue + .append(&proposal( + "p1", + ProposalKind::AddTask { + target_focus_id: "f1".into(), + task_text: "old".into(), + }, + )) + .unwrap(); + + let edit = ProposalEdit { + target_focus_id: Some("f2".into()), + task_text: Some("new".into()), + new_focus: None, + }; + let out = h.lifecycle.accept("p1", edit).unwrap(); + assert_eq!(out.target.as_deref(), Some("f2")); + + let body = fs::read_to_string(h.focuses_root.join("f2/focus.md")).unwrap(); + assert!(body.contains("- [ ] new")); + + let log = fs::read_to_string(&h.decisions_path).unwrap(); + assert!(log.contains("\"edited\":true")); + } + + #[test] + fn accept_unknown_id_returns_not_found() { + let h = build_lifecycle(); + let err = h + .lifecycle + .accept("missing", ProposalEdit::default()) + .unwrap_err(); + assert!(matches!(err, CommandError::NotFound(_))); + } + + #[test] + fn reject_records_decision_and_clears_queue_without_mutation() { + let h = build_lifecycle(); + h.queue + .append(&proposal("p1", ProposalKind::Discard)) + .unwrap(); + let out = h.lifecycle.reject("p1").unwrap(); + assert_eq!(out.target, None); + assert!(h.queue.list().unwrap().is_empty()); + + let log = fs::read_to_string(&h.decisions_path).unwrap(); + assert!(log.contains("\"decision\":\"reject\"")); + } +} diff --git a/crates/commands/src/proposal.rs b/crates/commands/src/proposal.rs index a78533e..700bb96 100644 --- a/crates/commands/src/proposal.rs +++ b/crates/commands/src/proposal.rs @@ -1,4 +1,4 @@ -use adhd_ranch_domain::{Decision, DecisionKind, NewFocus, Proposal, ProposalId, ProposalKind}; +use adhd_ranch_domain::{NewFocus, Proposal, ProposalId, ProposalKind}; use serde::{Deserialize, Serialize}; use crate::error::CommandError; @@ -83,88 +83,10 @@ impl Commands { id: &str, edit: ProposalEdit, ) -> Result { - let original = self.load_proposal(id)?; - let (proposal, edited) = apply_edit(original, &edit); - proposal.validate()?; - let outcome = self.dispatcher.apply(&proposal)?; - self.record_decision( - &proposal, - DecisionKind::Accept, - outcome.target.clone(), - edited, - )?; - self.queue.remove(&proposal.id)?; - Ok(DecisionOutcome { - id: id.to_string(), - target: outcome.target, - }) + self.lifecycle.accept(id, edit) } pub fn reject_proposal(&self, id: &str) -> Result { - let proposal = self.load_proposal(id)?; - self.record_decision(&proposal, DecisionKind::Reject, None, false)?; - self.queue.remove(&proposal.id)?; - Ok(DecisionOutcome { - id: id.to_string(), - target: None, - }) - } - - fn load_proposal(&self, id: &str) -> Result { - self.queue - .find(&ProposalId(id.to_string()))? - .ok_or_else(|| CommandError::NotFound(format!("proposal not found: {id}"))) - } - - fn record_decision( - &self, - proposal: &Proposal, - kind: DecisionKind, - target: Option, - edited: bool, - ) -> Result<(), CommandError> { - let decision = Decision { - ts: (self.clock)(), - proposal_id: proposal.id.0.clone(), - decision: kind, - reasoning: proposal.reasoning.clone(), - target, - edited, - }; - self.decisions.append(&decision)?; - Ok(()) - } -} - -fn apply_edit(mut proposal: Proposal, edit: &ProposalEdit) -> (Proposal, bool) { - let mut edited = false; - match &mut proposal.kind { - ProposalKind::AddTask { - target_focus_id, - task_text, - } => { - if let Some(new_id) = edit.target_focus_id.as_ref() { - if new_id != target_focus_id { - *target_focus_id = new_id.clone(); - edited = true; - } - } - if let Some(new_text) = edit.task_text.as_ref() { - if new_text != task_text { - *task_text = new_text.clone(); - edited = true; - } - } - } - ProposalKind::NewFocus { new_focus } => { - if let Some(replacement) = edit.new_focus.as_ref() { - if replacement != new_focus { - *new_focus = replacement.clone(); - edited = true; - } - } - } - ProposalKind::Discard => {} + self.lifecycle.reject(id) } - (proposal, edited) } diff --git a/crates/http-api/src/lib.rs b/crates/http-api/src/lib.rs index a5d6f87..9d691ee 100644 --- a/crates/http-api/src/lib.rs +++ b/crates/http-api/src/lib.rs @@ -1,6 +1,5 @@ pub mod router; pub mod serve; -pub use adhd_ranch_commands::ProposalDispatcher; pub use router::{router, router_with, FocusCatalogEntry, ServerDeps}; pub use serve::{serve, ServeError, ServerHandle}; diff --git a/crates/http-api/src/router/mod.rs b/crates/http-api/src/router/mod.rs index e8897c9..96b53ab 100644 --- a/crates/http-api/src/router/mod.rs +++ b/crates/http-api/src/router/mod.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use adhd_ranch_commands::{Clock, CommandError, Commands, IdGen, ProposalDispatcher}; +use adhd_ranch_commands::{Clock, CommandError, Commands, IdGen}; use adhd_ranch_domain::Settings; use adhd_ranch_storage::{DecisionLog, FocusStore, ProposalQueue}; use axum::http::StatusCode; @@ -36,16 +36,14 @@ pub fn router( store: Arc, queue: Arc, decisions: Arc, - dispatcher: Arc, ) -> Router { - router_with(store, queue, decisions, dispatcher, ServerDeps::default()) + router_with(store, queue, decisions, ServerDeps::default()) } pub fn router_with( store: Arc, queue: Arc, decisions: Arc, - dispatcher: Arc, deps: ServerDeps, ) -> Router { let clock: Clock = deps.clock.unwrap_or_else(|| Arc::new(now_rfc3339)); @@ -55,7 +53,7 @@ pub fn router_with( let settings = deps.settings.unwrap_or_default(); let commands = Arc::new(Commands::new( - store, queue, decisions, dispatcher, clock, id_gen, settings, + store, queue, decisions, clock, id_gen, settings, )); let state = AppState { commands }; diff --git a/crates/http-api/src/router/tests.rs b/crates/http-api/src/router/tests.rs index 83b72ee..9f88946 100644 --- a/crates/http-api/src/router/tests.rs +++ b/crates/http-api/src/router/tests.rs @@ -52,16 +52,10 @@ fn make_app(dir: &std::path::Path) -> Harness { let decisions_path = dir.join("decisions.jsonl"); let queue = Arc::new(JsonlProposalQueue::new(proposals_path.clone())); let decisions = Arc::new(JsonlDecisionLog::new(decisions_path.clone())); - let dispatcher = Arc::new(ProposalDispatcher::from_store( - store.clone(), - fixed_clock("2026-04-30T12:00:00Z"), - fixed_id("focus-id-1"), - )); let app = router_with( store, queue, decisions, - dispatcher, ServerDeps { clock: Some(fixed_clock("2026-04-30T12:00:00Z")), id_gen: Some(fixed_id("p-test")), diff --git a/crates/http-api/src/serve.rs b/crates/http-api/src/serve.rs index 5225b1d..1edaa8f 100644 --- a/crates/http-api/src/serve.rs +++ b/crates/http-api/src/serve.rs @@ -3,7 +3,6 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; -use adhd_ranch_commands::ProposalDispatcher; use adhd_ranch_storage::{DecisionLog, FocusStore, ProposalQueue}; use tokio::net::TcpListener; use tokio::sync::oneshot; @@ -76,7 +75,6 @@ pub async fn serve( store: Arc, queue: Arc, decisions: Arc, - dispatcher: Arc, port_file: Option, ) -> Result { let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)).await?; @@ -89,7 +87,7 @@ pub async fn serve( std::fs::write(path, addr.port().to_string())?; } - let app = router(store, queue, decisions, dispatcher); + let app = router(store, queue, decisions); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let join = tokio::spawn(async move { let _ = axum::serve(listener, app) @@ -136,14 +134,9 @@ mod tests { let store: Arc = Arc::new(MarkdownFocusStore::new(&focuses_root)); let queue = Arc::new(JsonlProposalQueue::new(dir.path().join("proposals.jsonl"))); let decisions = Arc::new(JsonlDecisionLog::new(dir.path().join("decisions.jsonl"))); - let dispatcher = Arc::new(ProposalDispatcher::from_store( - store.clone(), - Arc::new(|| "2026-04-30T12:00:00Z".to_string()), - Arc::new(|| "id".to_string()), - )); let port_file = dir.path().join("run/port"); - let handle = serve(store, queue, decisions, dispatcher, Some(port_file.clone())) + let handle = serve(store, queue, decisions, Some(port_file.clone())) .await .unwrap(); let port = std::fs::read_to_string(&port_file) diff --git a/src-tauri/src/app/cap_notifier.rs b/src-tauri/src/app/cap_notifier.rs new file mode 100644 index 0000000..1821a8c --- /dev/null +++ b/src-tauri/src/app/cap_notifier.rs @@ -0,0 +1,41 @@ +use adhd_ranch_commands::CapNotifier; +use tauri::AppHandle; +use tauri_plugin_notification::NotificationExt; + +pub struct TauriCapNotifier { + handle: AppHandle, +} + +impl TauriCapNotifier { + pub fn new(handle: AppHandle) -> Self { + Self { handle } + } +} + +impl CapNotifier for TauriCapNotifier { + fn focuses_over_cap(&self, max: usize) { + let _ = self + .handle + .notification() + .builder() + .title("Too many focuses") + .body(format!( + "You're over the limit of {max} focuses — trim one." + )) + .show(); + } + + fn focuses_under_cap(&self) {} + + fn task_over_cap(&self, focus_id: &str, max: usize) { + let _ = self + .handle + .notification() + .builder() + .title("Focus has too many tasks") + .body(format!("Focus {focus_id} has more than {max} tasks.")) + .show(); + } + + fn task_under_cap(&self, _focus_id: &str) {} +} diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 206f9f3..71e502f 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -1,21 +1,22 @@ +pub mod cap_notifier; pub mod paths; pub mod tray; use std::sync::Arc; use std::time::Duration; -use adhd_ranch_commands::{Commands, ProposalDispatcher}; -use adhd_ranch_domain::{cap_state, CapTransition, Caps, OverCapMonitor, Settings}; +use adhd_ranch_commands::{CapEvaluator, Commands}; +use adhd_ranch_domain::{OverCapMonitor, Settings}; use adhd_ranch_http_api::{serve, ServerHandle}; use adhd_ranch_storage::{ watch_path, DecisionLog, FocusStore, FocusWatcher, JsonlDecisionLog, JsonlProposalQueue, MarkdownFocusStore, ProposalQueue, }; use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_notification::NotificationExt; use time::format_description::well_known::Rfc3339; use crate::ui_bridge; +use cap_notifier::TauriCapNotifier; pub const FOCUSES_CHANGED_EVENT: &str = "focuses-changed"; pub const PROPOSALS_CHANGED_EVENT: &str = "proposals-changed"; @@ -59,50 +60,46 @@ pub fn run() { let decision_log: Arc = Arc::new(JsonlDecisionLog::new(decisions_path.clone())); - let dispatcher = Arc::new(ProposalDispatcher::from_store( - store.clone(), - Arc::new(now_rfc3339), - Arc::new(|| uuid::Uuid::now_v7().to_string()), - )); - let commands = Arc::new(Commands::new( store.clone(), queue.clone(), decision_log.clone(), - dispatcher.clone(), Arc::new(now_rfc3339), Arc::new(|| uuid::Uuid::now_v7().to_string()), settings, )); let monitor = Arc::new(OverCapMonitor::new()); + let notifier = Arc::new(TauriCapNotifier::new(app.handle().clone())); + let evaluator = Arc::new(CapEvaluator::new( + store.clone(), + monitor, + notifier, + settings, + )); app.manage(ui_bridge::CommandsState(commands)); - app.manage(ui_bridge::MonitorState(monitor.clone())); - - let cap_handle = app.handle().clone(); - let cap_store = store.clone(); - let cap_monitor = monitor.clone(); - let focuses_watcher = watch_path(&focuses_root, Duration::from_millis(200), move || { - let _ = cap_handle.emit(FOCUSES_CHANGED_EVENT, ()); - evaluate_caps( - &cap_handle, - cap_store.as_ref(), - cap_monitor.as_ref(), - settings, - ); - })?; - let proposals_watcher = install_watcher( - app.handle().clone(), + + let focuses_watcher = install_change_handlers( + &focuses_root, + vec![ + emit_event_handler(app.handle().clone(), FOCUSES_CHANGED_EVENT), + evaluate_caps_handler(evaluator.clone()), + ], + )?; + let proposals_watcher = install_change_handlers( proposals_path.parent().expect("proposals path has parent"), - PROPOSALS_CHANGED_EVENT, + vec![emit_event_handler( + app.handle().clone(), + PROPOSALS_CHANGED_EVENT, + )], )?; app.manage(WatcherHandles { _focuses: focuses_watcher, _proposals: proposals_watcher, }); - let server = install_http_server(store, queue, decision_log, dispatcher)?; + let server = install_http_server(store, queue, decision_log)?; app.manage(server); tray::install(app.handle())?; @@ -135,76 +132,48 @@ fn load_settings(path: &std::path::Path) -> Settings { } } -fn evaluate_caps( - handle: &AppHandle, - store: &dyn FocusStore, - monitor: &OverCapMonitor, - settings: Settings, -) { - let focuses = match store.list() { - Ok(f) => f, - Err(_) => return, - }; - let state = cap_state(&focuses, settings.caps); - let transition = monitor.evaluate(&state); - if !settings.alerts.system_notifications { - return; - } - notify_transitions(handle, &transition, settings.caps); -} - -fn notify_transitions(handle: &AppHandle, transition: &CapTransition, caps: Caps) { - if transition.focuses_to_over { - let _ = handle - .notification() - .builder() - .title("Too many focuses") - .body(format!( - "You're over the limit of {} focuses — trim one.", - caps.max_focuses - )) - .show(); - } - for id in &transition.task_to_over_focus_ids { - let _ = handle - .notification() - .builder() - .title("Focus has too many tasks") - .body(format!( - "Focus {id} has more than {} tasks.", - caps.max_tasks_per_focus - )) - .show(); - } -} - #[allow(dead_code)] struct WatcherHandles { _focuses: FocusWatcher, _proposals: FocusWatcher, } -fn install_watcher( - handle: AppHandle, +type ChangeHandler = Box; + +const WATCH_DEBOUNCE: Duration = Duration::from_millis(200); + +fn install_change_handlers( path: &std::path::Path, - event: &'static str, + handlers: Vec, ) -> Result> { - let watcher = watch_path(path, Duration::from_millis(200), move || { - let _ = handle.emit(event, ()); + let watcher = watch_path(path, WATCH_DEBOUNCE, move || { + for handler in &handlers { + handler(); + } })?; Ok(watcher) } +fn emit_event_handler(handle: AppHandle, event: &'static str) -> ChangeHandler { + Box::new(move || { + let _ = handle.emit(event, ()); + }) +} + +fn evaluate_caps_handler(evaluator: Arc) -> ChangeHandler { + Box::new(move || { + let _ = evaluator.evaluate(); + }) +} + fn install_http_server( store: Arc, queue: Arc, decisions: Arc, - dispatcher: Arc, ) -> Result> { let port_file = paths::port_file()?; let runtime = tauri::async_runtime::handle(); - let handle = runtime.block_on(async move { - serve(store, queue, decisions, dispatcher, Some(port_file)).await - })?; + let handle = + runtime.block_on(async move { serve(store, queue, decisions, Some(port_file)).await })?; Ok(handle) } diff --git a/src-tauri/src/ui_bridge/mod.rs b/src-tauri/src/ui_bridge/mod.rs index df03c20..e7cee47 100644 --- a/src-tauri/src/ui_bridge/mod.rs +++ b/src-tauri/src/ui_bridge/mod.rs @@ -3,14 +3,13 @@ use std::sync::Arc; use adhd_ranch_commands::{ Commands, CreateFocusInput, CreatedFocus, CreatedProposal, DecisionOutcome, ProposalEdit, }; -use adhd_ranch_domain::{Caps, Focus, OverCapMonitor, Proposal}; +use adhd_ranch_domain::{Caps, Focus, Proposal}; use tauri::State; use crate::api::Health; pub struct CommandsState(pub Arc); -pub struct MonitorState(pub Arc); #[tauri::command] pub fn health() -> Health { diff --git a/src/components/App.tsx b/src/components/App.tsx index aad1d8d..bcef1e0 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -3,9 +3,7 @@ import type { CapsReader } from "../api/caps"; import type { FocusWriter } from "../api/focusWriter"; import type { FocusReader } from "../api/focuses"; import type { ProposalReader, ProposalWriter } from "../api/proposals"; -import { useCaps } from "../hooks/useCaps"; -import { useFocuses } from "../hooks/useFocuses"; -import { useProposals } from "../hooks/useProposals"; +import { useAppState } from "../hooks/useAppState"; import { computeCapState } from "../lib/capState"; import { CapBadge } from "./CapBadge"; import { FocusList } from "./FocusList"; @@ -27,15 +25,13 @@ export function App({ proposalWriter, capsReader, }: AppProps) { - const focuses = useFocuses(focusReader); - const proposals = useProposals(proposalReader); - const caps = useCaps(capsReader); + const state = useAppState({ focusReader, proposalReader, capsReader }); const [busyFocusId, setBusyFocusId] = useState(null); const [error, setError] = useState(null); - const focusList = focuses.status === "ready" ? focuses.focuses : []; - const proposalList = proposals.status === "ready" ? proposals.proposals : []; - const capState = computeCapState(focusList, caps); + const focusList = state.status === "ready" ? state.focuses : []; + const proposalList = state.status === "ready" ? state.proposals : []; + const capState = computeCapState(focusList, state.caps); const wrap = async (focusId: string, run: () => Promise) => { setBusyFocusId(focusId); @@ -63,18 +59,18 @@ export function App({

adhd-ranch

- +
- {focuses.status === "loading" &&

Loading…

} - {focuses.status === "error" && ( + {state.status === "loading" &&

Loading…

} + {state.status === "error" && (

- {focuses.error.message} + {state.error.message}

)} - {focuses.status === "ready" && ( + {state.status === "ready" && ( Promise.resolve(caps) }; +} + +function failingFocusReader(error: Error): FocusReader { + return { list: () => Promise.reject(error) }; +} + +function failingProposalReader(error: Error): ProposalReader { + return { list: () => Promise.reject(error) }; +} + +describe("useAppState", () => { + it("starts loading with default caps", () => { + const focuses: Focus[] = []; + const proposals: Proposal[] = []; + const { result } = renderHook(() => + useAppState({ + focusReader: createFixtureFocusReader(focuses), + proposalReader: createFixtureProposalReader(proposals), + capsReader: fixtureCapsReader(), + }), + ); + expect(result.current.status).toBe("loading"); + expect(result.current.caps).toEqual(DEFAULT_CAPS); + }); + + it("becomes ready when both focuses and proposals resolve", async () => { + const focuses: Focus[] = [{ id: "a", title: "A", description: "", tasks: [] }]; + const proposals: Proposal[] = []; + const { result } = renderHook(() => + useAppState({ + focusReader: createFixtureFocusReader(focuses), + proposalReader: createFixtureProposalReader(proposals), + capsReader: fixtureCapsReader(), + }), + ); + await waitFor(() => { + expect(result.current.status).toBe("ready"); + }); + if (result.current.status === "ready") { + expect(result.current.focuses).toHaveLength(1); + expect(result.current.proposals).toHaveLength(0); + } + await waitFor(() => { + expect(result.current.caps).toEqual(fixtureCaps); + }); + }); + + it("surfaces a focus reader error", async () => { + const err = new Error("boom-focus"); + const { result } = renderHook(() => + useAppState({ + focusReader: failingFocusReader(err), + proposalReader: createFixtureProposalReader([]), + capsReader: fixtureCapsReader(), + }), + ); + await waitFor(() => { + expect(result.current.status).toBe("error"); + }); + if (result.current.status === "error") { + expect(result.current.error.message).toBe("boom-focus"); + } + }); + + it("surfaces a proposal reader error", async () => { + const err = new Error("boom-proposal"); + const { result } = renderHook(() => + useAppState({ + focusReader: createFixtureFocusReader([]), + proposalReader: failingProposalReader(err), + capsReader: fixtureCapsReader(), + }), + ); + await waitFor(() => { + expect(result.current.status).toBe("error"); + }); + if (result.current.status === "error") { + expect(result.current.error.message).toBe("boom-proposal"); + } + }); +}); diff --git a/src/hooks/useAppState.ts b/src/hooks/useAppState.ts new file mode 100644 index 0000000..efcf7fe --- /dev/null +++ b/src/hooks/useAppState.ts @@ -0,0 +1,43 @@ +import type { Caps, CapsReader } from "../api/caps"; +import type { FocusReader } from "../api/focuses"; +import type { ProposalReader } from "../api/proposals"; +import type { Focus } from "../types/focus"; +import type { Proposal } from "../types/proposal"; +import { useCaps } from "./useCaps"; +import { useFocuses } from "./useFocuses"; +import { useProposals } from "./useProposals"; + +export type AppStatus = + | { readonly status: "loading" } + | { readonly status: "error"; readonly error: Error } + | { + readonly status: "ready"; + readonly focuses: readonly Focus[]; + readonly proposals: readonly Proposal[]; + }; + +export type AppState = AppStatus & { readonly caps: Caps }; + +export interface AppStateDeps { + readonly focusReader: FocusReader; + readonly proposalReader: ProposalReader; + readonly capsReader: CapsReader; +} + +export function useAppState({ focusReader, proposalReader, capsReader }: AppStateDeps): AppState { + const focuses = useFocuses(focusReader); + const proposals = useProposals(proposalReader); + const caps = useCaps(capsReader); + + if (focuses.status === "error") return { status: "error", error: focuses.error, caps }; + if (proposals.status === "error") return { status: "error", error: proposals.error, caps }; + if (focuses.status === "loading" || proposals.status === "loading") { + return { status: "loading", caps }; + } + return { + status: "ready", + focuses: focuses.focuses, + proposals: proposals.proposals, + caps, + }; +}