app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 3d36437c70bd86cfcd4ae87a91c0296d8448f825
parent 91b653de3344007f5e848ba63e741b737697163d
Author: triesap <tyson@radroots.org>
Date:   Fri, 17 Apr 2026 20:46:30 +0000

state: share settings state and add the today projection

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 51+++++++++++++++++++++++++++++++++++++--------------
Mcrates/shared/models/src/lib.rs | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/shared/state/src/lib.rs | 237++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
3 files changed, 325 insertions(+), 71 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -4,10 +4,10 @@ use std::{ }; use radroots_app_core::{AppRuntimePathsError, AppRuntimeRoots}; -use radroots_app_models::{AppMode, SettingsSection}; +use radroots_app_models::{AppMode, SettingsSection, TodayAgendaProjection}; use radroots_app_sqlite::{AppSqliteError, AppSqliteStore, DatabaseTarget}; use radroots_app_state::{ - AppShellCommand, AppShellProjection, AppStateStore, AppStateStoreError, + AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, InMemoryAppStateRepository, SettingsPreference, }; use radroots_app_sync::{AppSyncProjection, SyncCheckpointStatus, SyncConflictStatus}; @@ -38,7 +38,8 @@ impl DesktopAppRuntime { logs_dir: state.logs_dir.clone(), database_path: state.database_path.clone(), sqlite_schema_version: state.sqlite_schema_version, - shell_projection: state.shell_store.projection().clone(), + shell_projection: state.state_store.shell_projection().clone(), + today_projection: state.state_store.today_projection().clone(), sync_projection: state.sync_projection.clone(), startup_issue: state.startup_issue.clone(), } @@ -46,27 +47,34 @@ impl DesktopAppRuntime { pub fn selected_settings_section(&self) -> SettingsSection { self.lock_state() - .shell_store - .projection() + .state_store + .shell_projection() .settings .selected_section } pub fn select_settings_section(&self, section: SettingsSection) -> bool { self.lock_state_mut() - .shell_store - .apply_in_memory(AppShellCommand::select_settings_section(section)) + .state_store + .apply_in_memory(AppStateCommand::select_settings_section(section)) } pub fn set_settings_preference(&self, preference: SettingsPreference, enabled: bool) -> bool { self.lock_state_mut() - .shell_store - .apply_in_memory(AppShellCommand::SetSettingsPreference { + .state_store + .apply_in_memory(AppStateCommand::SetSettingsPreference { preference, enabled, }) } + #[allow(dead_code)] + pub fn replace_today_agenda(&self, projection: TodayAgendaProjection) -> bool { + self.lock_state_mut() + .state_store + .apply_in_memory(AppStateCommand::replace_today_agenda(projection)) + } + fn from_state(state: DesktopAppRuntimeState) -> Self { Self { state: Arc::new(Mutex::new(state)), @@ -89,6 +97,8 @@ pub struct DesktopAppRuntimeSummary { pub database_path: Option<PathBuf>, pub sqlite_schema_version: Option<u32>, pub shell_projection: AppShellProjection, + #[allow(dead_code)] + pub today_projection: TodayAgendaProjection, pub sync_projection: AppSyncProjection, pub startup_issue: Option<String>, } @@ -99,7 +109,7 @@ struct DesktopAppRuntimeState { logs_dir: Option<PathBuf>, database_path: Option<PathBuf>, sqlite_schema_version: Option<u32>, - shell_store: AppStateStore<InMemoryAppStateRepository>, + state_store: AppStateStore<InMemoryAppStateRepository>, sync_projection: AppSyncProjection, startup_issue: Option<String>, } @@ -109,7 +119,7 @@ impl DesktopAppRuntimeState { let roots = AppRuntimeRoots::current_desktop()?; let database_path = roots.data.join(APP_DATABASE_FILE_NAME); let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?; - let shell_store = AppStateStore::load(InMemoryAppStateRepository::default())?; + let state_store = AppStateStore::load(InMemoryAppStateRepository::default())?; let sync_projection = AppSyncProjection { checkpoint: SyncCheckpointStatus::never_synced(), conflict_status: SyncConflictStatus::clear(), @@ -121,7 +131,7 @@ impl DesktopAppRuntimeState { logs_dir: Some(roots.logs), database_path: Some(database_path), sqlite_schema_version: Some(sqlite_store.schema_version()?), - shell_store, + state_store, sync_projection, startup_issue: None, }) @@ -133,7 +143,7 @@ impl DesktopAppRuntimeState { logs_dir: None, database_path: None, sqlite_schema_version: None, - shell_store: AppStateStore::in_memory(AppShellProjection { + state_store: AppStateStore::in_memory(AppShellProjection { app_mode: AppMode::Farmer, ..AppShellProjection::default() }), @@ -158,6 +168,7 @@ mod tests { use std::path::PathBuf; use radroots_app_core::{AppRuntimeHostEnvironment, AppRuntimePlatform, AppRuntimeRoots}; + use radroots_app_models::TodayAgendaProjection; use radroots_app_state::{AppStateStore, InMemoryAppStateRepository, SettingsPreference}; use radroots_app_sync::AppSyncProjection; @@ -197,7 +208,7 @@ mod tests { logs_dir: None, database_path: None, sqlite_schema_version: None, - shell_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), sync_projection: AppSyncProjection::default(), startup_issue: None, @@ -206,13 +217,25 @@ mod tests { assert!(runtime.select_settings_section(SettingsSection::About)); assert!(cloned_runtime.set_settings_preference(SettingsPreference::LaunchAtLogin, true)); + assert!(cloned_runtime.replace_today_agenda(TodayAgendaProjection { + setup_checklist: vec![radroots_app_models::TodaySetupTask { + kind: radroots_app_models::TodaySetupTaskKind::AddFulfillmentWindow, + is_complete: false, + }], + ..TodayAgendaProjection::default() + })); let summary = runtime.summary(); assert_eq!( + summary.shell_projection.selected_section, + radroots_app_models::ShellSection::Home + ); + assert_eq!( summary.shell_projection.settings.selected_section, SettingsSection::About ); assert!(summary.shell_projection.settings.general.launch_at_login); + assert!(summary.today_projection.needs_setup()); assert_eq!( cloned_runtime.selected_settings_section(), SettingsSection::About diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -238,6 +238,14 @@ pub struct FarmSummary { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FulfillmentWindowSummary { + pub fulfillment_window_id: FulfillmentWindowId, + pub farm_id: FarmId, + pub starts_at: String, + pub ends_at: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct TodaySummary { pub farm_id: FarmId, pub orders_needing_action: u32, @@ -257,21 +265,64 @@ pub struct ProductListRow { pub farm_id: FarmId, pub title: String, pub status: ProductStatus, + pub stock_count: u32, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct OrderListRow { pub order_id: OrderId, pub farm_id: FarmId, + pub fulfillment_window_id: Option<FulfillmentWindowId>, pub order_number: String, pub customer_display_name: String, pub status: OrderStatus, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TodaySetupTaskKind { + AddFulfillmentWindow, + PublishProduct, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TodaySetupTask { + pub kind: TodaySetupTaskKind, + pub is_complete: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct TodayAgendaProjection { + pub farm: Option<FarmSummary>, + pub summary: Option<TodaySummary>, + pub orders_needing_action: Vec<OrderListRow>, + pub low_stock_products: Vec<ProductListRow>, + pub draft_products: Vec<ProductListRow>, + pub next_fulfillment_window: Option<FulfillmentWindowSummary>, + pub setup_checklist: Vec<TodaySetupTask>, +} + +impl TodayAgendaProjection { + pub fn has_attention_items(&self) -> bool { + self.summary + .as_ref() + .is_some_and(TodaySummary::has_attention_items) + || !self.orders_needing_action.is_empty() + || !self.low_stock_products.is_empty() + || !self.draft_products.is_empty() + } + + pub fn needs_setup(&self) -> bool { + self.setup_checklist.iter().any(|item| !item.is_complete) + } +} + #[cfg(test)] mod tests { use super::{ - AppMode, BuyerSection, FarmId, FarmerSection, SettingsSection, ShellSection, TodaySummary, + AppMode, BuyerSection, FarmId, FarmerSection, OrderListRow, ProductListRow, + SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + TodaySummary, }; use std::{collections::BTreeSet, str::FromStr}; use uuid::Uuid; @@ -353,4 +404,59 @@ mod tests { assert!(!quiet.has_attention_items()); assert!(busy.has_attention_items()); } + + #[test] + fn today_agenda_projection_tracks_attention_and_setup_independently() { + let calm = TodayAgendaProjection::default(); + let with_attention = TodayAgendaProjection { + draft_products: vec![ProductListRow { + product_id: super::ProductId::new(), + farm_id: FarmId::new(), + title: "Spring onions".to_owned(), + status: super::ProductStatus::Draft, + stock_count: 0, + }], + ..TodayAgendaProjection::default() + }; + let with_setup = TodayAgendaProjection { + setup_checklist: vec![TodaySetupTask { + kind: TodaySetupTaskKind::AddFulfillmentWindow, + is_complete: false, + }], + ..TodayAgendaProjection::default() + }; + + assert!(!calm.has_attention_items()); + assert!(!calm.needs_setup()); + assert!(with_attention.has_attention_items()); + assert!(!with_attention.needs_setup()); + assert!(!with_setup.has_attention_items()); + assert!(with_setup.needs_setup()); + } + + #[test] + fn today_agenda_projection_can_hold_truthful_lists() { + let projection = TodayAgendaProjection { + orders_needing_action: vec![OrderListRow { + order_id: super::OrderId::new(), + farm_id: FarmId::new(), + fulfillment_window_id: Some(super::FulfillmentWindowId::new()), + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + status: super::OrderStatus::NeedsAction, + }], + low_stock_products: vec![ProductListRow { + product_id: super::ProductId::new(), + farm_id: FarmId::new(), + title: "Carrots".to_owned(), + status: super::ProductStatus::Published, + stock_count: 2, + }], + ..TodayAgendaProjection::default() + }; + + assert_eq!(projection.orders_needing_action.len(), 1); + assert_eq!(projection.low_stock_products[0].stock_count, 2); + assert!(projection.has_attention_items()); + } } diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use radroots_app_models::{AppMode, SettingsSection, ShellSection}; +use radroots_app_models::{AppMode, SettingsSection, ShellSection, TodayAgendaProjection}; use thiserror::Error; #[derive(Clone, Debug, Eq, PartialEq)] @@ -101,6 +101,26 @@ impl AppShellProjection { self.settings.selected_section = settings_section; } } + + fn select_settings_section(&mut self, selected_section: SettingsSection) { + self.settings.selected_section = selected_section; + + if matches!(self.selected_section, ShellSection::Settings(_)) { + self.selected_section = ShellSection::Settings(selected_section); + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AppProjection { + pub shell: AppShellProjection, + pub today: TodayAgendaProjection, +} + +impl AppProjection { + pub fn new(shell: AppShellProjection, today: TodayAgendaProjection) -> Self { + Self { shell, today } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -111,18 +131,24 @@ pub enum SettingsPreference { LaunchAtLogin, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AppShellCommand { +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AppStateCommand { SelectSection(ShellSection), + SelectSettingsSection(SettingsSection), SetSettingsPreference { preference: SettingsPreference, enabled: bool, }, + ReplaceTodayAgenda(TodayAgendaProjection), } -impl AppShellCommand { +impl AppStateCommand { pub const fn select_settings_section(section: SettingsSection) -> Self { - Self::SelectSection(ShellSection::Settings(section)) + Self::SelectSettingsSection(section) + } + + pub fn replace_today_agenda(projection: TodayAgendaProjection) -> Self { + Self::ReplaceTodayAgenda(projection) } } @@ -205,12 +231,15 @@ pub enum AppStateStoreError { #[derive(Clone, Debug)] pub struct AppStateStore<R> { repository: R, - projection: AppShellProjection, + projection: AppProjection, } impl<R: AppStateRepository> AppStateStore<R> { pub fn load(repository: R) -> Result<Self, AppStateStoreError> { - let projection = repository.load_shell_projection()?; + let projection = AppProjection::new( + repository.load_shell_projection()?, + TodayAgendaProjection::default(), + ); Ok(Self { repository, @@ -218,25 +247,40 @@ impl<R: AppStateRepository> AppStateStore<R> { }) } - pub fn projection(&self) -> &AppShellProjection { + pub fn projection(&self) -> &AppProjection { &self.projection } + pub fn shell_projection(&self) -> &AppShellProjection { + &self.projection.shell + } + + pub fn today_projection(&self) -> &TodayAgendaProjection { + &self.projection.today + } + pub fn repository(&self) -> &R { &self.repository } - pub fn apply(&mut self, command: AppShellCommand) -> Result<bool, AppStateStoreError> { + pub fn apply(&mut self, command: AppStateCommand) -> Result<bool, AppStateStoreError> { let mut next_projection = self.projection.clone(); - if !apply_command(&mut next_projection, command) { - return Ok(false); - } + match apply_command(&mut next_projection, command) { + AppStateMutation::NoChange => Ok(false), + AppStateMutation::ShellChanged => { + self.repository + .save_shell_projection(&next_projection.shell)?; + self.projection = next_projection; - self.repository.save_shell_projection(&next_projection)?; - self.projection = next_projection; + Ok(true) + } + AppStateMutation::TodayChanged => { + self.projection = next_projection; - Ok(true) + Ok(true) + } + } } } @@ -244,52 +288,82 @@ impl AppStateStore<InMemoryAppStateRepository> { pub fn in_memory(projection: AppShellProjection) -> Self { Self { repository: InMemoryAppStateRepository::new(projection.clone()), - projection, + projection: AppProjection::new(projection, TodayAgendaProjection::default()), } } - pub fn apply_in_memory(&mut self, command: AppShellCommand) -> bool { + pub fn apply_in_memory(&mut self, command: AppStateCommand) -> bool { let mut next_projection = self.projection.clone(); - if !apply_command(&mut next_projection, command) { - return false; - } + match apply_command(&mut next_projection, command) { + AppStateMutation::NoChange => false, + AppStateMutation::ShellChanged => { + self.repository.overwrite(next_projection.shell.clone()); + self.projection = next_projection; - self.repository.overwrite(next_projection.clone()); - self.projection = next_projection; + true + } + AppStateMutation::TodayChanged => { + self.projection = next_projection; - true + true + } + } } } -fn apply_command(projection: &mut AppShellProjection, command: AppShellCommand) -> bool { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AppStateMutation { + NoChange, + ShellChanged, + TodayChanged, +} + +fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> AppStateMutation { let before = projection.clone(); match command { - AppShellCommand::SelectSection(selected_section) => { - projection.select_section(selected_section); + AppStateCommand::SelectSection(selected_section) => { + projection.shell.select_section(selected_section); } - AppShellCommand::SetSettingsPreference { + AppStateCommand::SelectSettingsSection(selected_section) => { + projection.shell.select_settings_section(selected_section); + } + AppStateCommand::SetSettingsPreference { preference, enabled, } => { projection + .shell .settings .general .set_preference(preference, enabled); } + AppStateCommand::ReplaceTodayAgenda(today_projection) => { + projection.today = today_projection; + } } - *projection != before + if *projection == before { + AppStateMutation::NoChange + } else if projection.shell != before.shell { + AppStateMutation::ShellChanged + } else { + AppStateMutation::TodayChanged + } } #[cfg(test)] mod tests { use super::{ - AppShellCommand, AppShellProjection, AppStateRepository, AppStateRepositoryError, - AppStateStore, AppStateStoreError, InMemoryAppStateRepository, SettingsPreference, + AppProjection, AppShellProjection, AppStateCommand, AppStateRepository, + AppStateRepositoryError, AppStateStore, AppStateStoreError, InMemoryAppStateRepository, + SettingsPreference, + }; + use radroots_app_models::{ + AppMode, FarmerSection, SettingsSection, ShellSection, TodayAgendaProjection, + TodaySetupTask, TodaySetupTaskKind, }; - use radroots_app_models::{AppMode, SettingsSection, ShellSection}; struct FailingRepository; @@ -308,18 +382,19 @@ mod tests { #[test] fn default_projection_starts_on_farmer_home() { - let projection = AppShellProjection::default(); + let projection = AppProjection::default(); - assert_eq!(projection.app_mode, AppMode::Farmer); - assert_eq!(projection.selected_section, ShellSection::Home); + assert_eq!(projection.shell.app_mode, AppMode::Farmer); + assert_eq!(projection.shell.selected_section, ShellSection::Home); assert_eq!( - projection.settings.selected_section, + projection.shell.settings.selected_section, SettingsSection::Account ); - assert!(projection.settings.general.allow_relay_connections); - assert!(projection.settings.general.use_media_servers); - assert!(projection.settings.general.use_nip05); - assert!(!projection.settings.general.launch_at_login); + assert!(projection.shell.settings.general.allow_relay_connections); + assert!(projection.shell.settings.general.use_media_servers); + assert!(projection.shell.settings.general.use_nip05); + assert!(!projection.shell.settings.general.launch_at_login); + assert_eq!(projection.today, TodayAgendaProjection::default()); } #[test] @@ -329,39 +404,64 @@ mod tests { )); let store = AppStateStore::load(repository).expect("in-memory repository should load"); - assert_eq!(store.projection().app_mode, AppMode::Farmer); + assert_eq!(store.projection().shell.app_mode, AppMode::Farmer); assert_eq!( - store.projection().selected_section, + store.projection().shell.selected_section, ShellSection::Settings(SettingsSection::About) ); assert_eq!( - store.projection().settings.selected_section, + store.projection().shell.settings.selected_section, SettingsSection::About ); + assert_eq!(store.projection().today, TodayAgendaProjection::default()); } #[test] - fn select_settings_section_updates_projection_and_repository() { + fn select_settings_section_updates_shared_settings_without_clobbering_home() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); - let changed = store.apply(AppShellCommand::select_settings_section( + let changed = store.apply(AppStateCommand::select_settings_section( SettingsSection::Settings, )); assert_eq!(changed, Ok(true)); - assert_eq!(store.projection().app_mode, AppMode::Farmer); + assert_eq!(store.projection().shell.app_mode, AppMode::Farmer); assert_eq!( - store.projection().selected_section, - ShellSection::Settings(SettingsSection::Settings) + store.projection().shell.selected_section, + ShellSection::Home ); assert_eq!( - store.projection().settings.selected_section, + store.projection().shell.settings.selected_section, SettingsSection::Settings ); assert_eq!( store.repository().projection().selected_section, - ShellSection::Settings(SettingsSection::Settings) + ShellSection::Home + ); + assert_eq!( + store.repository().projection().settings.selected_section, + SettingsSection::Settings + ); + } + + #[test] + fn select_section_still_updates_the_root_shell() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + let changed = store.apply(AppStateCommand::SelectSection(ShellSection::Farmer( + FarmerSection::Products, + ))); + + assert_eq!(changed, Ok(true)); + assert_eq!( + store.projection().shell.selected_section, + ShellSection::Farmer(FarmerSection::Products) + ); + assert_eq!( + store.repository().projection().selected_section, + ShellSection::Farmer(FarmerSection::Products) ); } @@ -370,13 +470,13 @@ mod tests { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); - let changed = store.apply(AppShellCommand::SetSettingsPreference { + let changed = store.apply(AppStateCommand::SetSettingsPreference { preference: SettingsPreference::UseNip05, enabled: true, }); assert_eq!(changed, Ok(false)); - assert!(store.projection().settings.general.use_nip05); + assert!(store.projection().shell.settings.general.use_nip05); } #[test] @@ -384,13 +484,13 @@ mod tests { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); - let changed = store.apply(AppShellCommand::SetSettingsPreference { + let changed = store.apply(AppStateCommand::SetSettingsPreference { preference: SettingsPreference::LaunchAtLogin, enabled: true, }); assert_eq!(changed, Ok(true)); - assert!(store.projection().settings.general.launch_at_login); + assert!(store.projection().shell.settings.general.launch_at_login); assert!( store .repository() @@ -407,7 +507,7 @@ mod tests { AppStateStore::load(FailingRepository).expect("failing repository should still load"); let error = store - .apply(AppShellCommand::select_settings_section( + .apply(AppStateCommand::select_settings_section( SettingsSection::About, )) .expect_err("save should fail"); @@ -419,17 +519,42 @@ mod tests { } #[test] + fn replace_today_agenda_updates_in_memory_state_without_touching_repository() { + let mut store = + AppStateStore::load(FailingRepository).expect("failing repository should still load"); + let today = TodayAgendaProjection { + setup_checklist: vec![TodaySetupTask { + kind: TodaySetupTaskKind::AddFulfillmentWindow, + is_complete: false, + }], + ..TodayAgendaProjection::default() + }; + + let changed = store.apply(AppStateCommand::replace_today_agenda(today.clone())); + + assert_eq!(changed, Ok(true)); + assert_eq!(store.projection().today, today); + } + + #[test] fn in_memory_store_construction_and_updates_are_infallible() { let mut store = AppStateStore::in_memory(AppShellProjection::for_settings(SettingsSection::Account)); - let changed = store.apply_in_memory(AppShellCommand::SetSettingsPreference { + let changed = store.apply_in_memory(AppStateCommand::SetSettingsPreference { preference: SettingsPreference::AllowRelayConnections, enabled: false, }); assert!(changed); - assert!(!store.projection().settings.general.allow_relay_connections); + assert!( + !store + .projection() + .shell + .settings + .general + .allow_relay_connections + ); assert!( !store .repository()