commit 9c804bf0200c8fa3680720ec2881ba3e1e1a4bea
parent bf763654a74d90a91c122278ed613191ea929f0b
Author: triesap <tyson@radroots.org>
Date: Sun, 24 May 2026 08:26:03 +0000
app: surface buyer freshness errors
Diffstat:
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(),