Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions crates/commands/src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use adhd_ranch_domain::{
use adhd_ranch_storage::FocusStore;

use crate::error::CommandError;
use crate::SettingsProvider;

pub trait CapNotifier: Send + Sync {
fn focuses_over_cap(&self, max: usize);
Expand All @@ -18,7 +19,7 @@ pub struct CapEvaluator {
store: Arc<dyn FocusStore>,
monitor: Arc<OverCapMonitor>,
notifier: Arc<dyn CapNotifier>,
settings: Settings,
settings: SettingsProvider,
}

impl CapEvaluator {
Expand All @@ -27,6 +28,20 @@ impl CapEvaluator {
monitor: Arc<OverCapMonitor>,
notifier: Arc<dyn CapNotifier>,
settings: Settings,
) -> Self {
Self::new_with_settings_provider(
store,
monitor,
notifier,
Arc::new(move || settings.clone()),
)
}

pub fn new_with_settings_provider(
store: Arc<dyn FocusStore>,
monitor: Arc<OverCapMonitor>,
notifier: Arc<dyn CapNotifier>,
settings: SettingsProvider,
) -> Self {
Self {
store,
Expand All @@ -38,26 +53,23 @@ impl CapEvaluator {

pub fn evaluate(&self) -> Result<(), CommandError> {
let focuses = self.store.list()?;
let state = cap_state(&focuses, self.settings.caps);
let settings = self.settings.get();
let state = cap_state(&focuses, settings.caps);
let transition = self.monitor.evaluate(&state);

let focuses_enabled = self
.settings
.notifications
.is_enabled(&FocusesOverCapSource);
let tasks_enabled = self.settings.notifications.is_enabled(&TasksOverCapSource);
let focuses_enabled = settings.notifications.is_enabled(&FocusesOverCapSource);
let tasks_enabled = settings.notifications.is_enabled(&TasksOverCapSource);

if focuses_enabled && transition.focuses_to_over {
self.notifier
.focuses_over_cap(self.settings.caps.max_focuses);
self.notifier.focuses_over_cap(settings.caps.max_focuses);
}
if focuses_enabled && transition.focuses_to_under {
self.notifier.focuses_under_cap();
}
if tasks_enabled {
for id in &transition.task_to_over_focus_ids {
self.notifier
.task_over_cap(id, self.settings.caps.max_tasks_per_focus);
.task_over_cap(id, settings.caps.max_tasks_per_focus);
}
for id in &transition.task_to_under_focus_ids {
self.notifier.task_under_cap(id);
Expand Down Expand Up @@ -342,4 +354,31 @@ mod tests {
assert!(calls.contains(&Call::FocusesOver(5)));
assert!(!calls.contains(&Call::TaskOver("f0".into(), 7)));
}

#[test]
fn notification_source_toggle_is_read_from_latest_settings() {
let store = Arc::new(StubStore::new());
let notifier = Arc::new(RecordingNotifier::new());
let settings = Arc::new(Mutex::new(settings(all_enabled())));
let evaluator = CapEvaluator::new_with_settings_provider(
store.clone(),
Arc::new(OverCapMonitor::new()),
notifier.clone(),
{
let settings = settings.clone();
Arc::new(move || settings.lock().unwrap().clone())
},
);

store.set(vec![focus_with_tasks("a", 9)]);
settings
.lock()
.unwrap()
.notifications
.set(&adhd_ranch_domain::TasksOverCapSource, false);

evaluator.evaluate().unwrap();

assert!(notifier.calls().is_empty());
}
}
2 changes: 1 addition & 1 deletion crates/commands/src/focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ impl Commands {
}

pub fn caps(&self) -> Caps {
self.settings.caps
self.settings().caps
}
}

Expand Down
78 changes: 76 additions & 2 deletions crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,29 @@ pub type Clock = Arc<dyn Fn() -> String + Send + Sync>;
pub type ClockSecs = Arc<dyn Fn() -> i64 + Send + Sync>;
pub type IdGen = Arc<dyn Fn() -> String + Send + Sync>;

pub trait SettingsReader: Send + Sync {
fn get(&self) -> Settings;
}

impl<F> SettingsReader for F
where
F: Fn() -> Settings + Send + Sync,
{
fn get(&self) -> Settings {
self()
}
}

pub type SettingsProvider = Arc<dyn SettingsReader>;

pub struct Commands {
pub(crate) store: Arc<dyn FocusStore>,
pub(crate) queue: Arc<dyn ProposalQueue>,
pub(crate) lifecycle: Arc<ProposalLifecycle>,
pub(crate) clock: Clock,
pub(crate) clock_secs: ClockSecs,
pub(crate) id_gen: IdGen,
pub(crate) settings: Settings,
pub(crate) settings: SettingsProvider,
}

impl Commands {
Expand All @@ -42,6 +57,26 @@ impl Commands {
clock_secs: ClockSecs,
id_gen: IdGen,
settings: Settings,
) -> Self {
Self::new_with_settings_provider(
store,
queue,
decisions,
clock,
clock_secs,
id_gen,
Arc::new(move || settings.clone()),
)
}

pub fn new_with_settings_provider(
store: Arc<dyn FocusStore>,
queue: Arc<dyn ProposalQueue>,
decisions: Arc<dyn DecisionLog>,
clock: Clock,
clock_secs: ClockSecs,
id_gen: IdGen,
settings: SettingsProvider,
) -> Self {
let lifecycle = Arc::new(ProposalLifecycle::new(
store.clone(),
Expand All @@ -63,6 +98,45 @@ impl Commands {
}

pub fn settings(&self) -> Settings {
self.settings.clone()
self.settings.get()
}
}

#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};

use adhd_ranch_domain::{Caps, Settings};
use adhd_ranch_storage::{JsonlDecisionLog, JsonlProposalQueue, MarkdownFocusStore};
use tempfile::TempDir;

use super::*;

#[test]
fn caps_reads_latest_settings_provider_value() {
let dir = TempDir::new().unwrap();
let settings = Arc::new(Mutex::new(Settings::default()));
let commands = Commands::new_with_settings_provider(
Arc::new(MarkdownFocusStore::new(dir.path().join("focuses"))),
Arc::new(JsonlProposalQueue::new(dir.path().join("proposals.jsonl"))),
Arc::new(JsonlDecisionLog::new(dir.path().join("decisions.jsonl"))),
Arc::new(|| "2026-01-01T00:00:00Z".to_string()),
Arc::new(|| 1_700_000_000),
Arc::new(|| "id-fixed".to_string()),
{
let settings = settings.clone();
Arc::new(move || settings.lock().unwrap().clone())
},
);

assert_eq!(commands.caps().max_focuses, 5);

settings.lock().unwrap().caps = Caps {
max_focuses: 9,
max_tasks_per_focus: 11,
};

assert_eq!(commands.caps().max_focuses, 9);
assert_eq!(commands.caps().max_tasks_per_focus, 11);
}
}
2 changes: 1 addition & 1 deletion issues/031-notification-source-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ Notification toggles in Preferences are driven by the Rust `NotificationSource`

## Blocked by

None. 029 and 032 are done.
050 — settings update workflow and runtime freshness. 029 and 032 are done.
24 changes: 17 additions & 7 deletions issues/050-settings-update-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Move settings update coordination out of the Tauri command handler and into a de

Today `ui_bridge::update_settings` mutates in-memory Settings, persists `settings.yaml`, applies always-on-top behavior, updates display state, reapplies overlays, and rebuilds the tray menu inline. That makes the command handler the place where ordering and side effects live.

There is also a runtime freshness bug hiding behind that shallow module: settings are copied into several long-lived adapters at startup. `SettingsState` updates when Preferences changes, but `Commands::settings`, `CapEvaluator::settings`, and tray rebuild handlers keep their original `Settings` value. That means caps, notification source toggles, and tray badge state can disagree with the newly persisted `settings.yaml` until restart.

### Target shape

Add an app-side workflow module, for example `src-tauri/src/app/settings_workflow.rs`.
Expand All @@ -30,22 +32,29 @@ Adapters should cover:
- settings file persistence
- overlay always-on-top application
- display config reapply
- cap evaluator / command settings freshness
- tray menu rebuild

The workflow owns ordering. A reasonable initial invariant: persist first, then update memory and apply runtime effects. If implementation chooses a different invariant, document it in tests.

The workflow should make one committed `Settings` value authoritative for runtime consumers. It is acceptable to replace startup-copied `Settings` fields with a shared settings provider/adapter, or to update those adapters as part of the workflow, as long as the result is that Preferences changes take effect immediately without requiring app restart.

## Completion promise

`ui_bridge::update_settings` delegates to a settings workflow module; settings persistence, in-memory update, display effects, and tray rebuild ordering are tested through one interface.
`ui_bridge::update_settings` delegates to a settings workflow module; settings persistence, runtime settings freshness, display effects, cap/notification behavior, and tray rebuild ordering are tested through one interface.

## Acceptance criteria

- [ ] `src-tauri/src/app/settings_workflow.rs` exists
- [ ] `ui_bridge::update_settings` delegates to the workflow and contains no inline display/tray/window coordination
- [ ] The workflow uses injected adapters or small helper traits for side effects
- [ ] Tests cover persistence failure, display-change behavior, non-display widget setting behavior, and tray rebuild invocation
- [ ] The chosen ordering invariant is documented in test names or code comments
- [ ] `task check` green
- [x] `src-tauri/src/app/settings_workflow.rs` exists
- [x] `ui_bridge::update_settings` delegates to the workflow and contains no inline display/tray/window coordination
- [x] The workflow uses injected adapters or small helper traits for side effects
- [x] Runtime consumers that currently receive startup-copied `Settings` observe the updated Settings without restart
- [x] `get_caps` reflects updated cap settings after `update_settings`
- [x] Cap and timer notification source toggles take effect on the next evaluation/tick without restart
- [x] Tray menu rebuild and tray badge calculations use the updated caps
- [x] Tests cover persistence failure, display-change behavior, non-display widget setting behavior, runtime settings freshness, and tray rebuild invocation
- [x] The chosen ordering invariant is documented in test names or code comments
- [x] `task check` green

## Blocked by

Expand All @@ -54,3 +63,4 @@ None
## User stories addressed

- "When settings change, the app applies one coherent workflow instead of scattering persistence and side effects through the command handler."
- "When I change caps or notification toggles in Preferences, the app behavior changes immediately without restarting."
2 changes: 1 addition & 1 deletion issues/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Each issue is a self-contained vertical slice an AFK coding agent ("ralph") can

Complete this product slice before picking up architecture-only work.

- [050](050-settings-update-workflow.md) — Settings update workflow and runtime freshness
- [031](031-notification-source-settings.md) — Notification source settings registry (GH #31)

### Architecture queue (deepening, AFK except where noted)
Expand All @@ -14,7 +15,6 @@ These are not required before 031. Pick one when we choose to spend a slice on i

- [046](046-timer-expiry-service-extraction.md) — Timer expiry workflow module
- [047](047-storage-transaction-seam.md) — Storage transaction seam for Proposal lifecycle
- [050](050-settings-update-workflow.md) — Settings update workflow module
- [051](051-ranch-animal-vocabulary-seam.md) — RanchAnimal vocabulary seam for future animal types

Completed issue files live in `issues/done/`. Do not pick up files from `issues/done/` or `issues/icebox/`.
Expand Down
25 changes: 19 additions & 6 deletions src-tauri/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod cap_notifier;
pub mod menu;
pub mod paths;
pub mod seed;
pub mod settings_workflow;
pub mod timer_expiry;
pub mod tray;
pub mod window_always_on_top;
Expand Down Expand Up @@ -90,24 +91,36 @@ pub fn run() {
let decision_log: Arc<dyn DecisionLog> =
Arc::new(JsonlDecisionLog::new(decisions_path.clone()));

let commands = Arc::new(Commands::new(
let settings_state = Arc::new(Mutex::new(settings.clone()));
let settings_provider: adhd_ranch_commands::SettingsProvider = {
let settings_state = Arc::clone(&settings_state);
Arc::new(move || match settings_state.lock() {
Ok(settings) => settings.clone(),
Err(poisoned) => {
log::warn!("settings provider: lock poisoned; using recovered settings");
poisoned.into_inner().clone()
}
})
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let commands = Arc::new(Commands::new_with_settings_provider(
store.clone(),
queue.clone(),
decision_log.clone(),
Arc::new(now_rfc3339),
Arc::new(now_unix_secs),
Arc::new(|| uuid::Uuid::now_v7().to_string()),
settings.clone(),
Arc::clone(&settings_provider),
));
seed::ensure_example_focus(&commands, &focuses_root)?;

let cap_monitor = Arc::new(OverCapMonitor::new());
let notifier = Arc::new(TauriCapNotifier::new(app.handle().clone()));
let evaluator = Arc::new(CapEvaluator::new(
let evaluator = Arc::new(CapEvaluator::new_with_settings_provider(
store.clone(),
cap_monitor,
notifier,
settings.clone(),
Arc::clone(&settings_provider),
));

app.manage(ui_bridge::CommandsState(commands));
Expand All @@ -131,7 +144,7 @@ pub fn run() {
app.manage(DisplayConfigState(Arc::new(Mutex::new(
display_config.clone(),
))));
app.manage(SettingsState(Arc::new(Mutex::new(settings.clone()))));
app.manage(SettingsState(Arc::clone(&settings_state)));
app.manage(SettingsPathState(settings_path.clone()));
app.manage(DebugOverlayState(Arc::new(Mutex::new(false))));

Expand All @@ -156,7 +169,7 @@ pub fn run() {
tray_icon.clone(),
app.handle().clone(),
store.clone(),
settings,
Arc::clone(&settings_provider),
),
],
)?;
Expand Down
Loading
Loading