app

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

commit 9c804bf0200c8fa3680720ec2881ba3e1e1a4bea
parent bf763654a74d90a91c122278ed613191ea929f0b
Author: triesap <tyson@radroots.org>
Date:   Sun, 24 May 2026 08:26:03 +0000

app: surface buyer freshness errors

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/launchers/desktop/src/window.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 190 insertions(+), 15 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -1182,6 +1182,12 @@ impl DesktopAppRuntimeState { } else { false }; + let section_changed = self.apply_personal_section_selection(section); + + Ok(freshness_changed || section_changed) + } + + fn apply_personal_section_selection(&mut self, section: PersonalSection) -> bool { let section_changed = self .state_store .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Personal( @@ -1189,7 +1195,7 @@ impl DesktopAppRuntimeState { ))); let editor_changed = self.close_product_editor(); - Ok(freshness_changed || section_changed || editor_changed) + section_changed || editor_changed } fn refresh_personal_browse_navigation(&mut self) -> Result<bool, AppSqliteError> { @@ -1205,22 +1211,28 @@ impl DesktopAppRuntimeState { section: PersonalSection, product_id: ProductId, ) -> Result<bool, AppSqliteError> { + let should_refresh_before_lookup = + matches!(section, PersonalSection::Browse | PersonalSection::Search); + let freshness_changed = if should_refresh_before_lookup { + self.refresh_personal_browse_navigation()? + } else { + false + }; + let section_changed = if should_refresh_before_lookup { + self.apply_personal_section_selection(section) + } else { + false + }; let Some(sqlite_store) = self.sqlite_store.as_ref() else { - return Ok(false); + return Ok(freshness_changed || section_changed); }; let Some(detail) = sqlite_store.load_buyer_product_detail(product_id)? else { - return Ok(false); + return Ok(freshness_changed || section_changed); }; - let section_changed = - if matches!(section, PersonalSection::Browse | PersonalSection::Search) { - self.select_personal_section(section)? - } else { - false - }; let detail_changed = self.set_personal_product_detail(section, Some(detail)); - Ok(section_changed || detail_changed) + Ok(freshness_changed || section_changed || detail_changed) } fn close_personal_product_detail(&mut self, section: PersonalSection) -> bool { @@ -6081,6 +6093,18 @@ mod tests { } #[test] + fn runtime_buyer_detail_open_imports_shared_local_events_before_lookup() { + assert_detail_open_imports_shared_local_events_before_lookup( + "buyer_browse_detail_shared_local_events_refresh", + PersonalSection::Browse, + ); + assert_detail_open_imports_shared_local_events_before_lookup( + "buyer_search_detail_shared_local_events_refresh", + PersonalSection::Search, + ); + } + + #[test] fn runtime_app_farm_and_listing_writes_append_shared_local_work_records() { let (runtime, paths) = bootstrapped_runtime("app_local_work_records"); assert!( @@ -11342,6 +11366,73 @@ mod tests { .expect("append signed buyer listing"); } + fn deterministic_cli_listing_product_id( + owner_pubkey: Option<&str>, + listing_key: &str, + ) -> ProductId { + let seed = format!( + "radroots-cli-listing:{}:{}", + owner_pubkey.unwrap_or("unknown-owner"), + listing_key.trim() + ); + + ProductId::from(uuid::Uuid::new_v5( + &uuid::Uuid::NAMESPACE_URL, + seed.as_bytes(), + )) + } + + fn assert_detail_open_imports_shared_local_events_before_lookup( + label: &str, + section: PersonalSection, + ) { + let (runtime, paths) = bootstrapped_runtime(label); + assert!( + runtime + .generate_local_account(Some("Buyer".to_owned())) + .expect("account should generate") + ); + assert_eq!( + runtime + .summary() + .personal_projection + .browse + .listings + .rows + .len(), + 0 + ); + + let listing_key = "DDDDDDDDDDDDDDDDDDDDDD"; + append_cli_signed_buyer_listing_record_with( + &paths, + "detail-open-pending-listing", + listing_key, + "Buyer Visible Eggs", + 1100, + ); + let product_id = + deterministic_cli_listing_product_id(Some("buyer-visible-seller-pubkey"), listing_key); + + assert!( + runtime + .open_personal_product_detail(section, product_id) + .expect("buyer detail should import before lookup") + ); + let summary = runtime.summary(); + let detail = match section { + PersonalSection::Browse => summary.personal_projection.browse.detail, + PersonalSection::Search => summary.personal_projection.search.detail, + _ => None, + } + .expect("buyer detail should open from imported shared local events"); + + assert_eq!(detail.listing.product_id, product_id); + assert_eq!(detail.listing.title, "Buyer Visible Eggs"); + + cleanup_bootstrapped_runtime_paths(&paths); + } + fn local_work_record( record_id: &str, account_id: &str, diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -222,6 +222,7 @@ pub struct HomeView { products_stock_editor: Option<ProductsStockEditorState>, product_editor_form: Option<ProductEditorFormState>, relay_client: Option<RadrootsNostrClient>, + buyer_workspace_notice: Option<String>, } #[derive(Clone, Debug)] @@ -314,6 +315,7 @@ impl HomeView { products_stock_editor: None, product_editor_form: None, relay_client: None, + buyer_workspace_notice: None, } } @@ -1133,9 +1135,14 @@ impl HomeView { Ok(true) => { self.products_stock_editor = None; self.product_editor_form = None; + self.clear_buyer_workspace_notice(); cx.notify(); } - Ok(false) => {} + Ok(false) => { + if self.clear_buyer_workspace_notice() { + cx.notify(); + } + } Err(runtime_error) => { error!( target: "shell", @@ -1144,6 +1151,8 @@ impl HomeView { error = %runtime_error, "failed to select buyer section" ); + self.set_buyer_workspace_notice(runtime_error.to_string()); + cx.notify(); } } } @@ -1354,8 +1363,15 @@ impl HomeView { .runtime .open_personal_product_detail(section, product_id) { - Ok(true) => cx.notify(), - Ok(false) => {} + Ok(true) => { + self.clear_buyer_workspace_notice(); + cx.notify(); + } + Ok(false) => { + if self.clear_buyer_workspace_notice() { + cx.notify(); + } + } Err(runtime_error) => { error!( target: "buyer", @@ -1363,10 +1379,22 @@ impl HomeView { error = %runtime_error, "failed to open buyer product detail" ); + self.set_buyer_workspace_notice(runtime_error.to_string()); + cx.notify(); } } } + fn set_buyer_workspace_notice(&mut self, notice: String) -> bool { + let changed = self.buyer_workspace_notice.as_deref() != Some(notice.as_str()); + self.buyer_workspace_notice = Some(notice); + changed + } + + fn clear_buyer_workspace_notice(&mut self) -> bool { + self.buyer_workspace_notice.take().is_some() + } + fn close_personal_product_detail(&mut self, section: PersonalSection, cx: &mut Context<Self>) { if self.runtime.close_personal_product_detail(section) { cx.notify(); @@ -2721,6 +2749,9 @@ impl HomeView { cx.listener(|this, _, _, cx| this.open_account_entry(cx)), cx, )) + .when_some(self.buyer_workspace_notice.as_deref(), |this, notice| { + this.child(buyer_workspace_notice_card(notice.to_owned())) + }) .child( app_scroll_panel( buyer_content_scroll_id(selected_personal_section), @@ -12688,6 +12719,10 @@ fn home_empty_state_card(title_key: AppTextKey, body_key: AppTextKey) -> impl In ) } +fn buyer_workspace_notice_card(notice: String) -> impl IntoElement { + app_surface_card(home_body_text(notice)) +} + fn farm_setup_onboarding_card_spec(home_route: HomeRoute) -> Option<FarmSetupOnboardingCardSpec> { match home_route { HomeRoute::FarmSetupOnboarding => Some(FarmSetupOnboardingCardSpec { @@ -12826,7 +12861,7 @@ fn home_farm_order_method_label_key(method: FarmOrderMethod) -> AppTextKey { mod tests { use super::{ APP_UI_THEME, AppTextKey, FarmerHomeFarmState, HomeAutoFocusState, HomeAutoFocusTarget, - HomeStage, LabelValueRow, PackDayBatchPrintActionPresentation, + HomeStage, HomeView, LabelValueRow, PackDayBatchPrintActionPresentation, PackDayBatchPrintStatusPresentation, PackDayExportStatusPresentation, PackDayHostHandoffActionPresentation, PackDayHostHandoffStatusPresentation, PackDayPrintActionPresentation, PackDayPrintStatusPresentation, ReminderActionTarget, @@ -12857,6 +12892,9 @@ mod tests { DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSyncConflictSummary, DesktopAppSyncStatusSummary, }; + use radroots_app_core::{ + AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, + }; use radroots_app_models::SettingsAccountProjection; use radroots_app_models::{ ActiveSurface, AppStartupGate, BuyerOrderDetailProjection, BuyerOrderStatus, @@ -12887,7 +12925,11 @@ mod tests { SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncConflictStatus, }; use radroots_identity::RadrootsIdentity; - use std::{fs, path::PathBuf}; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; struct TestDirectory { path: PathBuf, @@ -12917,6 +12959,48 @@ mod tests { path } + fn test_home_view(label: &str) -> (HomeView, PathBuf) { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let home_dir = std::env::temp_dir().join(format!("radroots_home_view_{label}_{suffix}")); + let paths = AppDesktopRuntimePaths::for_desktop( + AppRuntimePlatform::Macos, + AppRuntimeHostEnvironment { + home_dir: Some(home_dir.clone()), + ..AppRuntimeHostEnvironment::default() + }, + ) + .expect("desktop runtime paths should resolve"); + let runtime = crate::runtime::DesktopAppRuntime::bootstrap_with_paths( + paths, + "wss://relay.example".to_owned(), + ); + + (HomeView::new(runtime), home_dir) + } + + #[test] + fn buyer_workspace_notice_tracks_visible_buyer_runtime_errors() { + let (mut view, home_dir) = test_home_view("buyer_notice"); + + assert!( + view.set_buyer_workspace_notice("open shared local events database failed".to_owned()) + ); + assert_eq!( + view.buyer_workspace_notice.as_deref(), + Some("open shared local events database failed") + ); + assert!( + !view.set_buyer_workspace_notice("open shared local events database failed".to_owned()) + ); + assert!(view.clear_buyer_workspace_notice()); + assert_eq!(view.buyer_workspace_notice, None); + + let _ = fs::remove_dir_all(home_dir); + } + fn sample_pack_day_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { PackDayExportBundle { fulfillment_window_id: FulfillmentWindowId::new(),