app

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

commit 2ae5a22861f924a3d114cbe762f449ce05baa244
parent d12294a8164afe313c5dea5a07a9fe787edccbff
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Apr 2026 23:25:11 +0000

runtime: wire shared orders and pack day routing

Diffstat:
Mcrates/launchers/desktop/src/app.rs | 2++
Mcrates/launchers/desktop/src/runtime.rs | 901++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/launchers/desktop/src/window.rs | 2++
Mcrates/shared/state/src/lib.rs | 293++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 929 insertions(+), 269 deletions(-)

diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -272,6 +272,8 @@ mod tests { farm_readiness_projection: FarmWorkspaceReadinessProjection::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + orders_projection: Default::default(), + pack_day_projection: Default::default(), logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: startup_issue.map(str::to_owned), } diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -5,37 +5,40 @@ use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedA use radroots_app_models::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, FarmId, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, - FarmSetupProjection, FarmSummary, FarmerSection, LoggedOutStartupProjection, - PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, - ProductsSort, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, + FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, + LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersListProjection, + OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PickupLocationRecord, + ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, ProductsSort, + SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, }; use radroots_app_sqlite::{ - APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, - derive_farm_rules_readiness, + derive_farm_rules_readiness, AppSqliteError, AppSqliteStore, DatabaseTarget, + APP_ACTIVITY_CONTEXT_LIMIT, }; use radroots_app_state::{ AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, FarmSetupFlowStage, FarmWorkspaceReadinessProjection, HomeRoute, InMemoryAppStateRepository, - ProductsScreenProjection, ProductsScreenQueryState, + OrdersScreenProjection, PackDayScreenProjection, ProductsScreenProjection, + ProductsScreenQueryState, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; use thiserror::Error; use tracing::error; use crate::accounts::{ - DesktopAccountsBootstrapError, DesktopAccountsCommandError, DesktopAccountsProjectionError, - DesktopLocalIdentityImportRequest, bootstrap_desktop_accounts, generate_local_account, - identity_projection_from_manager, import_local_account, remove_selected_local_key, - reset_local_device_state, select_active_surface, select_local_account, + bootstrap_desktop_accounts, generate_local_account, identity_projection_from_manager, + import_local_account, remove_selected_local_key, reset_local_device_state, + select_active_surface, select_local_account, DesktopAccountsBootstrapError, + DesktopAccountsCommandError, DesktopAccountsProjectionError, DesktopLocalIdentityImportRequest, }; use crate::remote_signer::{ - DesktopRemoteSignerError, DesktopRemoteSignerPaths, activate_pending_session, - apply_remote_signer_custody, clear_pending_session, load_pending_session, purge_all_state, - reconcile_startup, store_pending_session, + activate_pending_session, apply_remote_signer_custody, clear_pending_session, + load_pending_session, purge_all_state, reconcile_startup, store_pending_session, + DesktopRemoteSignerError, DesktopRemoteSignerPaths, }; const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; @@ -68,6 +71,8 @@ impl DesktopAppRuntime { farm_readiness_projection: state.state_store.farm_readiness_projection().clone(), today_projection: state.state_store.today_projection().clone(), products_projection: state.state_store.products_projection().clone(), + orders_projection: state.state_store.orders_projection().clone(), + pack_day_projection: state.state_store.pack_day_projection().clone(), startup_issue: state.startup_issue.clone(), } } @@ -193,6 +198,21 @@ impl DesktopAppRuntime { self.lock_state_mut().open_products_filter(filter) } + pub fn open_orders(&self) -> Result<bool, AppSqliteError> { + self.lock_state_mut().open_orders() + } + + pub fn open_order_detail(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { + self.lock_state_mut().open_order_detail(order_id) + } + + pub fn open_pack_day( + &self, + fulfillment_window_id: Option<FulfillmentWindowId>, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut().open_pack_day(fulfillment_window_id) + } + pub fn update_product_stock( &self, product_id: ProductId, @@ -383,6 +403,8 @@ pub struct DesktopAppRuntimeSummary { pub farm_readiness_projection: FarmWorkspaceReadinessProjection, pub today_projection: TodayAgendaProjection, pub products_projection: ProductsScreenProjection, + pub orders_projection: OrdersScreenProjection, + pub pack_day_projection: PackDayScreenProjection, pub startup_issue: Option<String>, } @@ -400,6 +422,9 @@ struct DesktopSelectedAccountContext { farm_rules_projection: FarmRulesProjection, today_projection: TodayAgendaProjection, products_list: ProductsListProjection, + orders_list: OrdersListProjection, + order_detail: Option<OrderDetailProjection>, + pack_day_projection: PackDayProjection, } struct DesktopAppRuntimeState { @@ -472,6 +497,13 @@ impl DesktopAppRuntimeState { &sqlite_store, &identity_projection, state_store.products_projection().query.clone(), + state_store.orders_projection().query.clone(), + state_store + .orders_projection() + .detail + .as_ref() + .map(|detail| detail.order_id), + state_store.pack_day_projection().query.clone(), )?; let _ = state_store.apply_in_memory(AppStateCommand::replace_identity_projection( identity_projection.clone(), @@ -481,6 +513,9 @@ impl DesktopAppRuntimeState { { let _ = state_store.apply_in_memory(AppStateCommand::show_startup_signer_entry()); } + let _ = state_store.apply_in_memory(AppStateCommand::replace_farm_rules_projection( + selected_account_context.farm_rules_projection, + )); let _ = state_store.apply_in_memory(AppStateCommand::replace_farm_setup_projection( selected_account_context.farm_setup_projection, )); @@ -490,6 +525,15 @@ impl DesktopAppRuntimeState { let _ = state_store.apply_in_memory(AppStateCommand::replace_products_list( selected_account_context.products_list, )); + let _ = state_store.apply_in_memory(AppStateCommand::replace_orders_list( + selected_account_context.orders_list, + )); + let _ = state_store.apply_in_memory(AppStateCommand::replace_order_detail( + selected_account_context.order_detail, + )); + let _ = state_store.apply_in_memory(AppStateCommand::replace_pack_day_projection( + selected_account_context.pack_day_projection, + )); Ok(Self { state_store, @@ -609,14 +653,35 @@ impl DesktopAppRuntimeState { section_changed || editor_changed } - FarmerSection::Products - if self.state_store.farm_setup_projection().has_saved_farm() => - { + FarmerSection::Products if self.has_saved_farm() => { self.state_store .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( FarmerSection::Products, ))) } + FarmerSection::Orders if self.has_saved_farm() => { + let section_changed = + self.state_store + .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( + FarmerSection::Orders, + ))); + let detail_changed = self + .state_store + .apply_in_memory(AppStateCommand::replace_order_detail(None)); + let editor_changed = self.close_product_editor(); + + section_changed || detail_changed || editor_changed + } + FarmerSection::PackDay if self.has_saved_farm() => { + let section_changed = + self.state_store + .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( + FarmerSection::PackDay, + ))); + let editor_changed = self.close_product_editor(); + + section_changed || editor_changed + } FarmerSection::Products | FarmerSection::Orders | FarmerSection::PackDay @@ -674,6 +739,68 @@ impl DesktopAppRuntimeState { Ok(filter_changed || section_changed) } + fn open_orders(&mut self) -> Result<bool, AppSqliteError> { + if !self.has_saved_farm() { + return Ok(false); + } + + let query_changed = + self.replace_orders_query(self.state_store.orders_projection().query.clone())?; + let section_changed = self + .state_store + .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( + FarmerSection::Orders, + ))); + let editor_changed = self.close_product_editor(); + + Ok(query_changed || section_changed || editor_changed) + } + + fn open_order_detail(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Ok(false); + }; + let Some(order_detail) = sqlite_store.load_order_detail(farm_id, order_id)? else { + return Ok(false); + }; + + let detail_changed = self + .state_store + .apply_in_memory(AppStateCommand::replace_order_detail(Some(order_detail))); + let section_changed = self + .state_store + .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( + FarmerSection::Orders, + ))); + let editor_changed = self.close_product_editor(); + + Ok(detail_changed || section_changed || editor_changed) + } + + fn open_pack_day( + &mut self, + fulfillment_window_id: Option<FulfillmentWindowId>, + ) -> Result<bool, AppSqliteError> { + if !self.has_saved_farm() { + return Ok(false); + } + + let query_changed = self.replace_pack_day_query(PackDayScreenQueryState { + fulfillment_window_id, + })?; + let section_changed = self + .state_store + .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( + FarmerSection::PackDay, + ))); + let editor_changed = self.close_product_editor(); + + Ok(query_changed || section_changed || editor_changed) + } + fn update_product_stock( &mut self, product_id: ProductId, @@ -700,6 +827,9 @@ impl DesktopAppRuntimeState { sqlite_store, self.state_store.identity_projection(), self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), )?; let context_changed = self.apply_selected_account_context(&selected_account_context); @@ -722,6 +852,9 @@ impl DesktopAppRuntimeState { sqlite_store, self.state_store.identity_projection(), self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), )?; let context_changed = self.apply_selected_account_context(&selected_account_context); let section_changed = self.select_farmer_section(FarmerSection::Products); @@ -780,6 +913,9 @@ impl DesktopAppRuntimeState { sqlite_store, self.state_store.identity_projection(), self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), )? }; let reloaded_draft = { @@ -914,6 +1050,9 @@ impl DesktopAppRuntimeState { sqlite_store, self.state_store.identity_projection(), self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), )? }; self.apply_selected_account_context(&selected_account_context); @@ -930,6 +1069,9 @@ impl DesktopAppRuntimeState { self.sqlite_store()?, &projection, self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), )?; let identity_changed = self .state_store @@ -947,6 +1089,9 @@ impl DesktopAppRuntimeState { self.sqlite_store_for_farm_setup()?, self.state_store.identity_projection(), self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), )?) } @@ -971,6 +1116,21 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::replace_products_list( context.products_list.clone(), )); + let orders_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_orders_list( + context.orders_list.clone(), + )); + let order_detail_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_order_detail( + context.order_detail.clone(), + )); + let pack_day_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_pack_day_projection( + context.pack_day_projection.clone(), + )); let editor_changed = if context.farm_setup_projection.has_saved_farm() { false } else { @@ -982,6 +1142,9 @@ impl DesktopAppRuntimeState { || farm_rules_changed || today_changed || products_changed + || orders_changed + || order_detail_changed + || pack_day_changed || editor_changed || shell_changed } @@ -1087,6 +1250,10 @@ impl DesktopAppRuntimeState { .map(|farm| farm.farm_id)) } + fn has_saved_farm(&self) -> bool { + self.state_store.farm_setup_projection().has_saved_farm() + } + fn selected_product_editor_id(&self) -> Option<ProductId> { match &self.state_store.products_projection().editor { radroots_app_state::ProductEditorState::Open(session) => session.selected_product_id, @@ -1094,6 +1261,14 @@ impl DesktopAppRuntimeState { } } + fn selected_order_detail_id(&self) -> Option<OrderId> { + self.state_store + .orders_projection() + .detail + .as_ref() + .map(|detail| detail.order_id) + } + fn fallback_farm_profile(&self, farm_id: FarmId) -> FarmProfileRecord { fallback_farm_profile_for_projection(farm_id, self.state_store.farm_setup_projection()) } @@ -1112,16 +1287,81 @@ impl DesktopAppRuntimeState { sqlite_store.load_products(farm_id, &query.search_query, query.filter, query.sort) } + fn replace_orders_query( + &mut self, + query: OrdersScreenQueryState, + ) -> Result<bool, AppSqliteError> { + let orders_list = self.load_orders_list_for_query(&query)?; + let filter_changed = self + .state_store + .apply_in_memory(AppStateCommand::select_orders_filter(query.filter)); + let fulfillment_window_changed = + self.state_store + .apply_in_memory(AppStateCommand::select_orders_fulfillment_window( + query.fulfillment_window_id, + )); + let list_changed = self + .state_store + .apply_in_memory(AppStateCommand::replace_orders_list(orders_list)); + + Ok(filter_changed || fulfillment_window_changed || list_changed) + } + + fn load_orders_list_for_query( + &self, + query: &OrdersScreenQueryState, + ) -> Result<OrdersListProjection, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(OrdersListProjection::default()); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Ok(OrdersListProjection::default()); + }; + + sqlite_store.load_orders_list(farm_id, query) + } + + fn replace_pack_day_query( + &mut self, + query: PackDayScreenQueryState, + ) -> Result<bool, AppSqliteError> { + let pack_day_projection = self.load_pack_day_for_query(&query)?; + let fulfillment_window_changed = + self.state_store + .apply_in_memory(AppStateCommand::set_pack_day_fulfillment_window( + query.fulfillment_window_id, + )); + let projection_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_pack_day_projection( + pack_day_projection, + )); + + Ok(fulfillment_window_changed || projection_changed) + } + + fn load_pack_day_for_query( + &self, + query: &PackDayScreenQueryState, + ) -> Result<PackDayProjection, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(PackDayProjection::default()); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Ok(PackDayProjection::default()); + }; + + sqlite_store.load_pack_day(farm_id, query) + } + fn sync_truthful_farmer_section(&mut self) -> bool { let selected_section = self.state_store.shell_projection().selected_section; let should_reset_to_today = match selected_section { ShellSection::Farmer(FarmerSection::Today) => false, - ShellSection::Farmer(FarmerSection::Products) => { - !self.state_store.farm_setup_projection().has_saved_farm() - } ShellSection::Farmer( - FarmerSection::Orders | FarmerSection::PackDay | FarmerSection::Farm, - ) => true, + FarmerSection::Products | FarmerSection::Orders | FarmerSection::PackDay, + ) => !self.has_saved_farm(), + ShellSection::Farmer(FarmerSection::Farm) => true, ShellSection::Home | ShellSection::Settings(_) => false, }; @@ -1274,6 +1514,9 @@ fn load_selected_account_context( sqlite_store: &AppSqliteStore, identity_projection: &AppIdentityProjection, products_query: ProductsScreenQueryState, + orders_query: OrdersScreenQueryState, + selected_order_id: Option<OrderId>, + pack_day_query: PackDayScreenQueryState, ) -> Result<DesktopSelectedAccountContext, AppSqliteError> { let Some(selected_account) = identity_projection.selected_account.as_ref() else { return Ok(DesktopSelectedAccountContext::default()); @@ -1310,12 +1553,27 @@ fn load_selected_account_context( )?, None => ProductsListProjection::default(), }; + let orders_list = match today_farm_id { + Some(farm_id) => sqlite_store.load_orders_list(farm_id, &orders_query)?, + None => OrdersListProjection::default(), + }; + let order_detail = match today_farm_id.zip(selected_order_id) { + Some((farm_id, order_id)) => sqlite_store.load_order_detail(farm_id, order_id)?, + None => None, + }; + let pack_day_projection = match today_farm_id { + Some(farm_id) => sqlite_store.load_pack_day(farm_id, &pack_day_query)?, + None => PackDayProjection::default(), + }; Ok(DesktopSelectedAccountContext { farm_setup_projection, farm_rules_projection, today_projection, products_list, + orders_list, + order_detail, + pack_day_projection, }) } @@ -1435,10 +1693,11 @@ mod tests { BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerActivationProjection, FarmerSection, - FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, PickupLocationId, - PickupLocationRecord, ProductEditorDraft, ProductStatus, ProductsFilter, ProductsSort, - SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, + OrdersFilter, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductStatus, + ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference, + SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + TodaySummary, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -1457,8 +1716,8 @@ mod tests { use crate::accounts::DesktopLocalIdentityImportRequest; use super::{ - APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeActivityContextError, - DesktopAppRuntimeCommandError, DesktopAppRuntimeState, DesktopRemoteSignerPaths, + DesktopAppRuntime, DesktopAppRuntimeActivityContextError, DesktopAppRuntimeCommandError, + DesktopAppRuntimeState, DesktopRemoteSignerPaths, APP_DATABASE_FILE_NAME, }; #[test] @@ -1542,12 +1801,10 @@ mod tests { assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); assert_eq!(summary.home_route, HomeRoute::SetupRequired); assert!(summary.settings_account_projection.roster.is_empty()); - assert!( - summary - .settings_account_projection - .selected_account - .is_none() - ); + assert!(summary + .settings_account_projection + .selected_account + .is_none()); assert_eq!( summary.logged_out_startup, LoggedOutStartupProjection::default() @@ -1634,11 +1891,9 @@ mod tests { startup_issue: None, }); - assert!( - runtime - .clear_startup_pending_remote_signer_session() - .expect("clear pending should succeed") - ); + assert!(runtime + .clear_startup_pending_remote_signer_session() + .expect("clear pending should succeed")); assert!(runtime.begin_generate_key_startup()); assert_eq!( runtime.summary().logged_out_startup.phase, @@ -1653,11 +1908,9 @@ mod tests { let (runtime, paths) = bootstrapped_runtime("restart_pending_recovery"); let pending_session = fixture_pending_session(); - assert!( - runtime - .store_startup_pending_remote_signer_session(&pending_session) - .expect("store pending should succeed") - ); + assert!(runtime + .store_startup_pending_remote_signer_session(&pending_session) + .expect("store pending should succeed")); let restarted = restart_runtime(paths.clone()); let restored = restarted @@ -1686,16 +1939,12 @@ mod tests { let (runtime, paths) = bootstrapped_runtime("restart_after_explicit_cancel"); let pending_session = fixture_pending_session(); - assert!( - runtime - .store_startup_pending_remote_signer_session(&pending_session) - .expect("store pending should succeed") - ); - assert!( - runtime - .clear_startup_pending_remote_signer_session() - .expect("clear pending should succeed") - ); + assert!(runtime + .store_startup_pending_remote_signer_session(&pending_session) + .expect("store pending should succeed")); + assert!(runtime + .clear_startup_pending_remote_signer_session() + .expect("clear pending should succeed")); let restarted = restart_runtime(paths.clone()); @@ -1940,6 +2189,8 @@ mod tests { }); assert!(!runtime.select_farmer_section(FarmerSection::Products)); + assert!(!runtime.select_farmer_section(FarmerSection::Orders)); + assert!(!runtime.select_farmer_section(FarmerSection::PackDay)); assert_eq!( runtime.summary().shell_projection.selected_section, ShellSection::Home @@ -1950,11 +2201,9 @@ mod tests { fn runtime_routes_between_farmer_home_and_products_through_explicit_methods() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -1990,11 +2239,9 @@ mod tests { .expect("sqlite store") .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); assert!(runtime.select_farmer_section(FarmerSection::Products)); assert_eq!( @@ -2013,11 +2260,9 @@ mod tests { fn runtime_products_queries_refresh_the_repository_backed_projection() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2072,11 +2317,9 @@ mod tests { "2026-04-18T09:00:00Z", ); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); let summary = runtime.summary(); assert_eq!(summary.products_projection.list.summary.total_products, 2); @@ -2090,18 +2333,14 @@ mod tests { ProductsSort::default() ); - assert!( - runtime - .select_products_filter(ProductsFilter::NeedAttention) - .expect("filter should apply") - ); + assert!(runtime + .select_products_filter(ProductsFilter::NeedAttention) + .expect("filter should apply")); assert_eq!(runtime.summary().products_projection.list.rows.len(), 2); - assert!( - runtime - .set_products_search_query("pea") - .expect("search should apply") - ); + assert!(runtime + .set_products_search_query("pea") + .expect("search should apply")); let searched = runtime.summary(); assert_eq!(searched.products_projection.list.rows.len(), 1); assert_eq!( @@ -2109,11 +2348,9 @@ mod tests { "Pea shoots" ); - assert!( - runtime - .select_products_sort(ProductsSort::Name) - .expect("sort should apply") - ); + assert!(runtime + .select_products_sort(ProductsSort::Name) + .expect("sort should apply")); assert_eq!( runtime.summary().products_projection.query.sort, ProductsSort::Name @@ -2124,11 +2361,9 @@ mod tests { fn runtime_open_products_filter_routes_today_follow_ons_into_products() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2165,21 +2400,17 @@ mod tests { .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); assert_eq!( runtime.summary().shell_projection.selected_section, ShellSection::Farmer(FarmerSection::Today) ); - assert!( - runtime - .open_products_filter(ProductsFilter::Drafts) - .expect("products follow-on should route") - ); + assert!(runtime + .open_products_filter(ProductsFilter::Drafts) + .expect("products follow-on should route")); let summary = runtime.summary(); assert_eq!( @@ -2193,14 +2424,79 @@ mod tests { } #[test] - fn runtime_stock_updates_refresh_today_and_products_projections() { + fn runtime_opens_orders_detail_and_pack_day_through_shared_farmer_routing() { let runtime = memory_runtime(); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (fulfillment_window_id, order_id) = seed_order_workspace(&runtime, farm_id); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") + assert_eq!( + runtime.summary().orders_projection.query.filter, + OrdersFilter::NeedsAction + ); + + assert!(runtime.open_orders().expect("orders should open")); + let orders_summary = runtime.summary(); + assert_eq!( + orders_summary.shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Orders) + ); + assert_eq!(orders_summary.orders_projection.list.rows.len(), 1); + assert_eq!( + orders_summary.orders_projection.list.rows[0].order_id, + order_id + ); + assert!(orders_summary.orders_projection.detail.is_none()); + + assert!(runtime + .open_order_detail(order_id) + .expect("order detail should open")); + let detail_summary = runtime.summary(); + assert_eq!( + detail_summary.shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Orders) + ); + assert_eq!( + detail_summary + .orders_projection + .detail + .as_ref() + .expect("order detail") + .order_id, + order_id + ); + + assert!(runtime.open_pack_day(None).expect("pack day should open")); + let pack_day_summary = runtime.summary(); + assert_eq!( + pack_day_summary.shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::PackDay) + ); + assert_eq!( + pack_day_summary + .pack_day_projection + .query + .fulfillment_window_id, + None + ); + assert_eq!( + pack_day_summary + .pack_day_projection + .projection + .fulfillment_window + .as_ref() + .expect("pack day fulfillment window") + .fulfillment_window_id, + fulfillment_window_id ); + } + + #[test] + fn runtime_stock_updates_refresh_today_and_products_projections() { + let runtime = memory_runtime(); + + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2246,22 +2542,18 @@ mod tests { "2026-04-18T10:00:00Z", ); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); let product_id = runtime.summary().products_projection.list.rows[0].product_id; assert_eq!( runtime.summary().today_projection.low_stock_products.len(), 1 ); - assert!( - runtime - .update_product_stock(product_id, 12) - .expect("stock update should succeed") - ); + assert!(runtime + .update_product_stock(product_id, 12) + .expect("stock update should succeed")); let summary = runtime.summary(); assert_eq!( @@ -2275,11 +2567,9 @@ mod tests { fn runtime_open_new_product_editor_creates_a_local_draft_and_opens_it() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2316,11 +2606,9 @@ mod tests { .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); assert_eq!( runtime .summary() @@ -2331,11 +2619,9 @@ mod tests { 0 ); - assert!( - runtime - .open_new_product_editor() - .expect("new product editor should open") - ); + assert!(runtime + .open_new_product_editor() + .expect("new product editor should open")); let summary = runtime.summary(); assert_eq!(summary.products_projection.list.summary.total_products, 1); @@ -2353,11 +2639,9 @@ mod tests { fn runtime_open_existing_and_save_product_editor_refreshes_products_projection() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2403,16 +2687,12 @@ mod tests { "2026-04-18T10:00:00Z", ); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); - assert!( - runtime - .open_existing_product_editor(product_id) - .expect("existing product editor should open") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); + assert!(runtime + .open_existing_product_editor(product_id) + .expect("existing product editor should open")); let saved_draft = ProductEditorDraft { title: "Salad mix".to_owned(), @@ -2425,11 +2705,9 @@ mod tests { status: radroots_app_models::ProductStatus::Published, }; - assert!( - runtime - .save_product_editor_draft(saved_draft.clone()) - .expect("product editor draft should save") - ); + assert!(runtime + .save_product_editor_draft(saved_draft.clone()) + .expect("product editor draft should save")); let summary = runtime.summary(); assert_eq!( @@ -2466,11 +2744,9 @@ mod tests { fn runtime_account_commands_refresh_identity_projection() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("First".to_owned())) - .expect("first account should generate") - ); + assert!(runtime + .generate_local_account(Some("First".to_owned())) + .expect("first account should generate")); let first_summary = runtime.summary(); let first_account_id = first_summary .settings_account_projection @@ -2481,11 +2757,9 @@ mod tests { .account_id .clone(); - assert!( - runtime - .generate_local_account(Some("Second".to_owned())) - .expect("second account should generate") - ); + assert!(runtime + .generate_local_account(Some("Second".to_owned())) + .expect("second account should generate")); let second_summary = runtime.summary(); let second_account_id = second_summary .settings_account_projection @@ -2511,11 +2785,9 @@ mod tests { ActiveSurface::Farmer, true, ); - assert!( - runtime - .select_local_account(second_account_id.as_str()) - .expect("selection should succeed") - ); + assert!(runtime + .select_local_account(second_account_id.as_str()) + .expect("selection should succeed")); let selected_summary = runtime.summary(); assert_eq!(selected_summary.startup_gate, AppStartupGate::Farmer); assert_eq!(selected_summary.home_route, HomeRoute::FarmSetupOnboarding); @@ -2528,11 +2800,9 @@ mod tests { Some(ActiveSurface::Farmer) ); - assert!( - runtime - .remove_selected_local_key() - .expect("selected local key should remove") - ); + assert!(runtime + .remove_selected_local_key() + .expect("selected local key should remove")); let removed_summary = runtime.summary(); assert_eq!(removed_summary.settings_account_projection.roster.len(), 1); assert_eq!( @@ -2555,13 +2825,11 @@ mod tests { ); let imported_identity = RadrootsIdentity::generate(); - assert!( - runtime - .import_local_account(DesktopLocalIdentityImportRequest::raw_secret_key( - imported_identity.nsec(), - )) - .expect("raw import should succeed") - ); + assert!(runtime + .import_local_account(DesktopLocalIdentityImportRequest::raw_secret_key( + imported_identity.nsec(), + )) + .expect("raw import should succeed")); let imported_summary = runtime.summary(); assert_eq!(imported_summary.settings_account_projection.roster.len(), 2); assert_eq!( @@ -2578,11 +2846,9 @@ mod tests { fn runtime_select_active_surface_persists_selected_surface() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2593,18 +2859,14 @@ mod tests { .account_id .clone(); save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); assert_eq!(runtime.summary().startup_gate, AppStartupGate::Farmer); - assert!( - runtime - .select_active_surface(ActiveSurface::Personal) - .expect("surface should select") - ); + assert!(runtime + .select_active_surface(ActiveSurface::Personal) + .expect("surface should select")); let personal_summary = runtime.summary(); assert_eq!(personal_summary.startup_gate, AppStartupGate::Personal); assert_eq!( @@ -2628,11 +2890,9 @@ mod tests { ActiveSurface::Personal ); - assert!( - runtime - .select_active_surface(ActiveSurface::Farmer) - .expect("surface should reselect") - ); + assert!(runtime + .select_active_surface(ActiveSurface::Farmer) + .expect("surface should reselect")); let farmer_summary = runtime.summary(); assert_eq!(farmer_summary.startup_gate, AppStartupGate::Farmer); assert_eq!( @@ -2661,11 +2921,9 @@ mod tests { fn selecting_farmer_account_loads_persisted_farm_setup_draft() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2689,11 +2947,9 @@ mod tests { .expect("farm setup should save"); save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); let summary = runtime.summary(); assert_eq!(summary.startup_gate, AppStartupGate::Farmer); @@ -2705,11 +2961,9 @@ mod tests { fn finishing_farm_setup_persists_saved_farm_and_today_projection() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2721,11 +2975,9 @@ mod tests { .clone(); let farm_id = save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); assert_eq!(runtime.summary().home_route, HomeRoute::FarmSetupOnboarding); let draft = FarmSetupDraft::new( @@ -2788,11 +3040,9 @@ mod tests { fn loading_farm_rules_projection_seeds_profile_from_saved_farm() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2829,11 +3079,9 @@ mod tests { .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); let projection = runtime .load_farm_rules_projection() @@ -2862,11 +3110,9 @@ mod tests { fn saving_farm_rules_projection_refreshes_saved_farm_summary_and_pickup_defaults() { let runtime = memory_runtime(); - assert!( - runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate") - ); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); let account_id = runtime .summary() .settings_account_projection @@ -2903,11 +3149,9 @@ mod tests { .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!( - runtime - .select_local_account(account_id.as_str()) - .expect("account should select") - ); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); let default_pickup_location_id = PickupLocationId::new(); let market_pickup_location_id = PickupLocationId::new(); @@ -3040,11 +3284,9 @@ mod tests { fn runtime_reset_local_device_state_clears_store_file_and_projection() { let (runtime, paths) = file_backed_runtime("reset"); - assert!( - runtime - .generate_local_account(Some("First".to_owned())) - .expect("first account should generate") - ); + assert!(runtime + .generate_local_account(Some("First".to_owned())) + .expect("first account should generate")); let first_account_id = runtime .summary() .settings_account_projection @@ -3054,11 +3296,9 @@ mod tests { .account .account_id .clone(); - assert!( - runtime - .generate_local_account(Some("Second".to_owned())) - .expect("second account should generate") - ); + assert!(runtime + .generate_local_account(Some("Second".to_owned())) + .expect("second account should generate")); let second_account_id = runtime .summary() .settings_account_projection @@ -3082,21 +3322,17 @@ mod tests { ); assert!(paths.store_path.exists()); - assert!( - runtime - .reset_local_device_state() - .expect("device state should reset") - ); + assert!(runtime + .reset_local_device_state() + .expect("device state should reset")); let summary = runtime.summary(); assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); assert!(summary.settings_account_projection.roster.is_empty()); - assert!( - summary - .settings_account_projection - .selected_account - .is_none() - ); + assert!(summary + .settings_account_projection + .selected_account + .is_none()); assert!(!paths.store_path.exists()); assert_eq!( runtime @@ -3394,6 +3630,149 @@ mod tests { product_id } + fn provision_ready_farmer_account(runtime: &DesktopAppRuntime) -> (String, FarmId) { + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); + let account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + .clone(); + let farm_id = + save_farmer_surface_activation(runtime, account_id.as_str(), ActiveSurface::Farmer); + let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { + farm_id, + display_name: "North field farm".to_owned(), + readiness: FarmReadiness::Ready, + }); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_farm_summary( + farm_setup_projection + .saved_farm + .as_ref() + .expect("saved farm should exist"), + ) + .expect("farm summary should save"); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_farm_setup(account_id.as_str(), &farm_setup_projection) + .expect("farm setup should save"); + assert!(runtime + .select_local_account(account_id.as_str()) + .expect("account should select")); + + (account_id, farm_id) + } + + fn seed_order_workspace( + runtime: &DesktopAppRuntime, + farm_id: FarmId, + ) -> (FulfillmentWindowId, OrderId) { + let pickup_location_id = PickupLocationId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + let order_id = OrderId::new(); + let sql = format!( + "insert into pickup_locations ( + id, + farm_id, + label, + address_line, + directions, + is_default, + created_at, + updated_at + ) values ( + '{pickup_location_id}', + '{farm_id}', + 'North barn', + '14 County Road', + null, + 1, + '2026-04-17T08:00:00Z', + '2026-04-17T08:00:00Z' + ); + insert into fulfillment_windows ( + id, + farm_id, + starts_at, + ends_at, + capacity_limit, + created_at, + updated_at, + pickup_location_id, + label, + order_cutoff_at + ) values ( + '{fulfillment_window_id}', + '{farm_id}', + '2099-04-18T16:00:00Z', + '2099-04-18T18:00:00Z', + null, + '2099-04-18T16:00:00Z', + '2099-04-18T16:00:00Z', + '{pickup_location_id}', + 'Friday pickup', + '2099-04-17T18:00:00Z' + ); + insert into orders ( + id, + farm_id, + fulfillment_window_id, + order_number, + customer_display_name, + status, + updated_at + ) values ( + '{order_id}', + '{farm_id}', + '{fulfillment_window_id}', + 'R-100', + 'Casey', + 'needs_action', + '2026-04-17T10:00:00Z' + ); + insert into order_lines ( + id, + order_id, + title, + quantity_value, + quantity_unit_label, + quantity_display, + sort_index + ) values ( + 'line-1', + '{order_id}', + 'Salad mix', + 2, + 'bags', + '2 bags', + 0 + )", + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&sql) + .expect("orders workspace should seed"); + + (fulfillment_window_id, order_id) + } + fn cleanup_paths(paths: &AppSharedAccountsPaths) { let Some(base) = paths.data_root.ancestors().nth(3).map(PathBuf::from) else { return; diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -7154,6 +7154,8 @@ mod tests { farm_setup_projection, today_projection, products_projection: Default::default(), + orders_projection: Default::default(), + pack_day_projection: Default::default(), startup_issue: None, } } diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -3,8 +3,10 @@ use radroots_app_models::{ ActiveSurface, AppIdentityProjection, AppStartupGate, FarmReadiness, FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection, FarmSetupReadiness, - FarmTimingConflict, LoggedOutStartupPhase, LoggedOutStartupProjection, ProductEditorDraft, - ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, + FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase, LoggedOutStartupProjection, + OrderDetailProjection, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, + PackDayProjection, PackDayScreenQueryState, ProductEditorDraft, ProductId, + ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; @@ -208,6 +210,41 @@ pub struct ProductsScreenProjection { pub editor: ProductEditorState, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct OrdersScreenProjection { + pub list: OrdersListProjection, + pub query: OrdersScreenQueryState, + pub detail: Option<OrderDetailProjection>, +} + +impl OrdersScreenProjection { + fn select_filter(&mut self, filter: OrdersFilter) { + self.query.filter = filter; + self.detail = None; + } + + fn select_fulfillment_window(&mut self, fulfillment_window_id: Option<FulfillmentWindowId>) { + self.query.fulfillment_window_id = fulfillment_window_id; + self.detail = None; + } + + fn replace_detail(&mut self, detail: Option<OrderDetailProjection>) { + self.detail = detail; + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PackDayScreenProjection { + pub query: PackDayScreenQueryState, + pub projection: PackDayProjection, +} + +impl PackDayScreenProjection { + fn select_fulfillment_window(&mut self, fulfillment_window_id: Option<FulfillmentWindowId>) { + self.query.fulfillment_window_id = fulfillment_window_id; + } +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum FarmSetupFlowStage { #[default] @@ -346,6 +383,8 @@ pub struct AppProjection { pub logged_out_startup: LoggedOutStartupProjection, pub today: TodayAgendaProjection, pub products: ProductsScreenProjection, + pub orders: OrdersScreenProjection, + pub pack_day: PackDayScreenProjection, pub farm_setup: FarmSetupProjection, pub farm_rules: FarmRulesProjection, pub farm_readiness: FarmWorkspaceReadinessProjection, @@ -374,6 +413,8 @@ impl AppProjection { logged_out_startup: LoggedOutStartupProjection::default(), today, products: ProductsScreenProjection::default(), + orders: OrdersScreenProjection::default(), + pack_day: PackDayScreenProjection::default(), farm_setup, farm_rules: FarmRulesProjection::default(), farm_readiness: FarmWorkspaceReadinessProjection::default(), @@ -434,6 +475,12 @@ pub enum AppStateCommand { SelectProductsFilter(ProductsFilter), SelectProductsSort(ProductsSort), ReplaceProductsList(ProductsListProjection), + SelectOrdersFilter(OrdersFilter), + SelectOrdersFulfillmentWindow(Option<FulfillmentWindowId>), + ReplaceOrdersList(OrdersListProjection), + ReplaceOrderDetail(Option<OrderDetailProjection>), + SetPackDayFulfillmentWindow(Option<FulfillmentWindowId>), + ReplacePackDayProjection(PackDayProjection), OpenNewProductEditor, OpenExistingProductEditor { product_id: ProductId, @@ -508,6 +555,34 @@ impl AppStateCommand { Self::ReplaceProductsList(projection) } + pub const fn select_orders_filter(filter: OrdersFilter) -> Self { + Self::SelectOrdersFilter(filter) + } + + pub fn select_orders_fulfillment_window( + fulfillment_window_id: Option<FulfillmentWindowId>, + ) -> Self { + Self::SelectOrdersFulfillmentWindow(fulfillment_window_id) + } + + pub fn replace_orders_list(projection: OrdersListProjection) -> Self { + Self::ReplaceOrdersList(projection) + } + + pub fn replace_order_detail(projection: Option<OrderDetailProjection>) -> Self { + Self::ReplaceOrderDetail(projection) + } + + pub fn set_pack_day_fulfillment_window( + fulfillment_window_id: Option<FulfillmentWindowId>, + ) -> Self { + Self::SetPackDayFulfillmentWindow(fulfillment_window_id) + } + + pub fn replace_pack_day_projection(projection: PackDayProjection) -> Self { + Self::ReplacePackDayProjection(projection) + } + pub const fn open_new_product_editor() -> Self { Self::OpenNewProductEditor } @@ -657,6 +732,14 @@ impl<R: AppStateRepository> AppStateStore<R> { &self.projection.products } + pub fn orders_projection(&self) -> &OrdersScreenProjection { + &self.projection.orders + } + + pub fn pack_day_projection(&self) -> &PackDayScreenProjection { + &self.projection.pack_day + } + pub fn home_route(&self) -> HomeRoute { self.projection.home_route() } @@ -705,6 +788,16 @@ impl<R: AppStateRepository> AppStateStore<R> { Ok(true) } + AppStateMutation::OrdersChanged => { + self.projection = next_projection; + + Ok(true) + } + AppStateMutation::PackDayChanged => { + self.projection = next_projection; + + Ok(true) + } } } } @@ -752,6 +845,16 @@ impl AppStateStore<InMemoryAppStateRepository> { true } + AppStateMutation::OrdersChanged => { + self.projection = next_projection; + + true + } + AppStateMutation::PackDayChanged => { + self.projection = next_projection; + + true + } } } } @@ -764,6 +867,8 @@ enum AppStateMutation { StartupChanged, TodayChanged, ProductsChanged, + OrdersChanged, + PackDayChanged, } fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> AppStateMutation { @@ -851,6 +956,28 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateCommand::ReplaceProductsList(products_projection) => { projection.products.list = products_projection; } + AppStateCommand::SelectOrdersFilter(filter) => { + projection.orders.select_filter(filter); + } + AppStateCommand::SelectOrdersFulfillmentWindow(fulfillment_window_id) => { + projection + .orders + .select_fulfillment_window(fulfillment_window_id); + } + AppStateCommand::ReplaceOrdersList(orders_projection) => { + projection.orders.list = orders_projection; + } + AppStateCommand::ReplaceOrderDetail(order_detail_projection) => { + projection.orders.replace_detail(order_detail_projection); + } + AppStateCommand::SetPackDayFulfillmentWindow(fulfillment_window_id) => { + projection + .pack_day + .select_fulfillment_window(fulfillment_window_id); + } + AppStateCommand::ReplacePackDayProjection(pack_day_projection) => { + projection.pack_day.projection = pack_day_projection; + } AppStateCommand::OpenNewProductEditor => { projection .products @@ -890,6 +1017,10 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateMutation::StartupChanged } else if projection.products != before.products { AppStateMutation::ProductsChanged + } else if projection.orders != before.orders { + AppStateMutation::OrdersChanged + } else if projection.pack_day != before.pack_day { + AppStateMutation::PackDayChanged } else { AppStateMutation::TodayChanged } @@ -1133,17 +1264,20 @@ mod tests { use super::{ AppProjection, AppShellProjection, AppStateCommand, AppStateRepository, AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute, - InMemoryAppStateRepository, ProductEditorState, ProductsScreenProjection, - ProductsScreenQueryState, SettingsPreference, + InMemoryAppStateRepository, OrdersScreenProjection, PackDayScreenProjection, + ProductEditorState, ProductsScreenProjection, ProductsScreenQueryState, SettingsPreference, }; use radroots_app_models::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, LoggedOutStartupPhase, - LoggedOutStartupProjection, ProductEditorDraft, ProductId, ProductPublishBlocker, - ProductsFilter, ProductsListProjection, ProductsSort, SelectedAccountProjection, - SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection, - TodaySetupTask, TodaySetupTaskKind, + LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, + OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, + OrdersListSummary, OrdersScreenQueryState, PackDayPackListRow, PackDayProductTotalRow, + PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, ProductEditorDraft, + ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, + SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; struct FailingRepository; @@ -1199,6 +1333,8 @@ mod tests { assert!(!projection.shell.settings.general.launch_at_login); assert_eq!(projection.today, TodayAgendaProjection::default()); assert_eq!(projection.products, ProductsScreenProjection::default()); + assert_eq!(projection.orders, OrdersScreenProjection::default()); + assert_eq!(projection.pack_day, PackDayScreenProjection::default()); assert_eq!(projection.farm_setup, FarmSetupProjection::default()); assert_eq!( projection.farm_setup_flow_stage, @@ -1237,6 +1373,11 @@ mod tests { store.projection().products, ProductsScreenProjection::default() ); + assert_eq!(store.projection().orders, OrdersScreenProjection::default()); + assert_eq!( + store.projection().pack_day, + PackDayScreenProjection::default() + ); assert_eq!(store.home_route(), HomeRoute::SetupRequired); } @@ -1291,6 +1432,142 @@ mod tests { } #[test] + fn orders_and_pack_day_queries_refresh_as_local_app_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let farm_id = FarmId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + let order_id = OrderId::new(); + let orders_list = OrdersListProjection { + summary: OrdersListSummary { + total_orders: 2, + needs_action_orders: 1, + scheduled_orders: 1, + packed_orders: 0, + }, + rows: vec![OrdersListRow { + order_id, + farm_id, + fulfillment_window_id: Some(fulfillment_window_id), + order_number: "R-100".to_owned(), + customer_display_name: "Casey".to_owned(), + fulfillment_window_label: Some("Friday pickup".to_owned()), + pickup_location_label: Some("North barn".to_owned()), + status: OrderStatus::NeedsAction, + primary_action: Some(OrderPrimaryAction::Review), + }], + }; + let order_detail = OrderDetailProjection { + order_id, + farm_id, + order_number: "R-100".to_owned(), + customer_display_name: "Casey".to_owned(), + status: OrderStatus::NeedsAction, + fulfillment_window_id: Some(fulfillment_window_id), + fulfillment_window_label: Some("Friday pickup".to_owned()), + pickup_location_label: Some("North barn".to_owned()), + items: vec![OrderDetailItemRow { + title: "Salad mix".to_owned(), + quantity_display: "2 bags".to_owned(), + }], + primary_action: Some(OrderPrimaryAction::Review), + }; + let pack_day = PackDayProjection { + fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { + fulfillment_window_id, + farm_id, + pickup_location_id: None, + label: "Friday pickup".to_owned(), + starts_at: "2026-04-18T16:00:00Z".to_owned(), + ends_at: "2026-04-18T18:00:00Z".to_owned(), + order_cutoff_at: "2026-04-17T18:00:00Z".to_owned(), + }), + totals_by_product: vec![PackDayProductTotalRow { + title: "Salad mix".to_owned(), + quantity_display: "2 bags".to_owned(), + }], + pack_list: vec![PackDayPackListRow { + title: "Salad mix".to_owned(), + quantity_display: "Casey: 2 bags".to_owned(), + }], + pickup_roster: vec![PackDayRosterRow { + order_id, + order_number: "R-100".to_owned(), + customer_display_name: "Casey".to_owned(), + }], + }; + + assert_eq!( + store.projection().orders.query, + OrdersScreenQueryState::default() + ); + assert_eq!( + store.projection().pack_day.query, + PackDayScreenQueryState::default() + ); + + assert_eq!( + store.apply(AppStateCommand::select_orders_filter(OrdersFilter::Packed)), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::select_orders_fulfillment_window(Some( + fulfillment_window_id, + ))), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::replace_orders_list(orders_list.clone())), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::replace_order_detail(Some( + order_detail.clone() + ))), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some( + fulfillment_window_id, + ))), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::replace_pack_day_projection( + pack_day.clone() + )), + Ok(true) + ); + assert_eq!( + store.projection().orders.query, + OrdersScreenQueryState { + filter: OrdersFilter::Packed, + fulfillment_window_id: Some(fulfillment_window_id), + } + ); + assert_eq!(store.projection().orders.list, orders_list); + assert_eq!(store.projection().orders.detail, Some(order_detail)); + assert_eq!( + store.projection().pack_day.query, + PackDayScreenQueryState { + fulfillment_window_id: Some(fulfillment_window_id), + } + ); + assert_eq!(store.projection().pack_day.projection, pack_day); + assert_eq!( + store.apply(AppStateCommand::select_orders_filter( + OrdersFilter::NeedsAction + )), + Ok(true) + ); + assert_eq!(store.projection().orders.detail, None); + assert_eq!( + store.repository().projection(), + &AppShellProjection::default() + ); + } + + #[test] fn startup_identity_choice_flow_is_explicit_and_in_memory_only() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load");