app

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

commit 6e2f0527fcb4143eb736d423c263c8fc5a38de6d
parent 5d361b7ea4fb9c80c6e1664acfe17ce26dd8f63d
Author: triesap <tyson@radroots.org>
Date:   Sun, 24 May 2026 22:17:23 +0000

app: add buyer order retry action

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/launchers/desktop/src/window.rs | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/shared/i18n/src/keys.rs | 3+++
Mcrates/shared/i18n/src/lib.rs | 12++++++++++++
Mcrates/shared/state/src/lib.rs | 1+
Mi18n/locales/en/messages.json | 3+++
6 files changed, 174 insertions(+), 13 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -1466,6 +1466,10 @@ impl DesktopAppRuntimeState { projection.orders.detail = Some(order_detail.clone()); changed = true; } + if !projection.orders.has_recoverable_coordination { + projection.orders.has_recoverable_coordination = true; + changed = true; + } changed }); @@ -1488,8 +1492,10 @@ impl DesktopAppRuntimeState { } else { false }; + let coordination_changed = + self.refresh_personal_orders_coordination_retry_state(&buyer_context)?; - Ok(personal_changed || section_changed || pending_changed) + Ok(personal_changed || section_changed || pending_changed || coordination_changed) } fn retry_pending_personal_order_coordination(&mut self) -> Result<bool, AppSqliteError> { @@ -1500,6 +1506,9 @@ impl DesktopAppRuntimeState { }; sqlite_store.load_recoverable_buyer_order_coordination_records(&buyer_context)? }; + if records.is_empty() { + return self.refresh_personal_orders_coordination_retry_state(&buyer_context); + } let mut changed = false; let mut refreshed_order_id = None; @@ -1551,6 +1560,26 @@ impl DesktopAppRuntimeState { Ok(changed) } + fn refresh_personal_orders_coordination_retry_state( + &mut self, + buyer_context: &BuyerContext, + ) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let has_recoverable_coordination = !sqlite_store + .load_recoverable_buyer_order_coordination_records(buyer_context)? + .is_empty(); + Ok(self.mutate_personal_projection(|projection| { + if projection.orders.has_recoverable_coordination == has_recoverable_coordination { + false + } else { + projection.orders.has_recoverable_coordination = has_recoverable_coordination; + true + } + })) + } + fn refresh_personal_orders_projection( &mut self, buyer_context: &BuyerContext, @@ -1563,13 +1592,22 @@ impl DesktopAppRuntimeState { .detail .as_ref() .map(|detail| detail.order_id); - let (refreshed_cart, refreshed_checkout, refreshed_orders, refreshed_order_detail) = { + let ( + refreshed_cart, + refreshed_checkout, + refreshed_orders, + refreshed_order_detail, + has_recoverable_coordination, + ) = { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(false); }; let refreshed_cart = sqlite_store.load_buyer_cart(buyer_context)?; let refreshed_checkout = sqlite_store.load_buyer_checkout(buyer_context)?; let refreshed_orders = sqlite_store.load_buyer_orders(buyer_context)?; + let has_recoverable_coordination = !sqlite_store + .load_recoverable_buyer_order_coordination_records(buyer_context)? + .is_empty(); let detail_order_id = current_detail_order_id .filter(|order_id| { refreshed_orders @@ -1594,6 +1632,7 @@ impl DesktopAppRuntimeState { refreshed_checkout, refreshed_orders, refreshed_order_detail, + has_recoverable_coordination, ) }; @@ -1615,6 +1654,10 @@ impl DesktopAppRuntimeState { projection.orders.detail = refreshed_order_detail.clone(); changed = true; } + if projection.orders.has_recoverable_coordination != has_recoverable_coordination { + projection.orders.has_recoverable_coordination = has_recoverable_coordination; + changed = true; + } changed })) @@ -4652,6 +4695,9 @@ fn load_selected_account_context_with_options( let buyer_cart = sqlite_store.load_buyer_cart(&buyer_context)?; let buyer_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; let buyer_orders = sqlite_store.load_buyer_orders(&buyer_context)?; + let has_recoverable_coordination = !sqlite_store + .load_recoverable_buyer_order_coordination_records(&buyer_context)? + .is_empty(); let buyer_order_detail = match continuity_state.buyer.orders_detail_order_id { Some(order_id) => sqlite_store.load_buyer_order_detail(&buyer_context, order_id)?, None => None, @@ -4673,6 +4719,7 @@ fn load_selected_account_context_with_options( orders: BuyerOrdersScreenProjection { list: buyer_orders, detail: buyer_order_detail, + has_recoverable_coordination, }, ..PersonalWorkspaceProjection::default() }; @@ -8872,6 +8919,12 @@ mod tests { .expect("same-session buyer order recovery retry should sync") ); let summary_after_retry = runtime.summary(); + assert!( + !summary_after_retry + .personal_projection + .orders + .has_recoverable_coordination + ); assert_eq!( pending_order_sync_payloads(&runtime, buyer_account_id.as_str(), order_id), vec![expected_order_sync_payload.clone()] @@ -8955,6 +9008,12 @@ mod tests { vec![format!("app:local_work:order_request:{order_id}")] ); let summary = restarted_runtime.summary(); + assert!( + !summary + .personal_projection + .orders + .has_recoverable_coordination + ); assert_eq!(summary.personal_projection.orders.list.rows.len(), 1); assert_eq!( summary.personal_projection.orders.list.rows[0].order_id, @@ -12580,6 +12639,12 @@ mod tests { summary.shell_projection.selected_section, ShellSection::Personal(PersonalSection::Orders) ); + assert!( + summary + .personal_projection + .orders + .has_recoverable_coordination + ); assert!(summary.personal_projection.cart.cart.lines.is_empty()); assert!(!summary.personal_projection.cart.checkout.can_place_order); assert_eq!(summary.personal_projection.orders.list.rows.len(), 1); diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -40,9 +40,9 @@ use radroots_app_remote_signer::{ }; use radroots_app_sqlite::{AppSqliteError, derive_farm_rules_readiness}; use radroots_app_state::{ - FarmSetupFlowStage, FarmWorkspaceStatus, HomeRoute, PackDayBatchPrintRequest, - PackDayExportProjection, PackDayHostHandoffRequest, PackDayPrintRequest, - derive_product_publish_blockers, + BuyerOrdersScreenProjection, FarmSetupFlowStage, FarmWorkspaceStatus, HomeRoute, + PackDayBatchPrintRequest, PackDayExportProjection, PackDayHostHandoffRequest, + PackDayPrintRequest, derive_product_publish_blockers, }; use radroots_app_sync::{ AppSyncRunStatus, SyncAggregateRef, SyncCheckpointState, SyncConflict, SyncConflictKind, @@ -1570,7 +1570,35 @@ impl HomeView { error = %runtime_error, "failed to place buyer order" ); - self.set_buyer_workspace_notice(notice) + let notice_changed = self.set_buyer_workspace_notice(notice); + buyer_order_coordination_notice_forces_redraw(notice) || notice_changed + } + } + } + + fn retry_pending_personal_order_coordination(&mut self, cx: &mut Context<Self>) { + if self.retry_pending_personal_order_coordination_update() { + cx.notify(); + } + } + + fn retry_pending_personal_order_coordination_update(&mut self) -> bool { + match self.runtime.retry_pending_personal_order_coordination() { + Ok(true) => { + let _ = self.clear_buyer_workspace_notice(); + true + } + Ok(false) => false, + Err(runtime_error) => { + error!( + target: "buyer", + event = "buyer.order_coordination_retry_failed", + error = %runtime_error, + "failed to retry buyer order coordination" + ); + let notice = BuyerWorkspaceNotice::OrderCoordinationFailed; + let notice_changed = self.set_buyer_workspace_notice(notice); + buyer_order_coordination_notice_forces_redraw(notice) || notice_changed } } } @@ -3149,6 +3177,9 @@ impl HomeView { AppTextKey::HomeNavOrders, AppTextKey::PersonalOrdersSurfaceBody, )) + .when(buyer_orders_retry_action_visible(orders), |this| { + this.child(buyer_orders_retry_card(cx)) + }) .child(if orders.list.rows.is_empty() { home_empty_state_card( AppTextKey::PersonalOrdersEmptyTitle, @@ -8569,6 +8600,28 @@ fn buyer_orders_list_card( .into_any_element() } +fn buyer_orders_retry_action_visible(orders: &BuyerOrdersScreenProjection) -> bool { + orders.has_recoverable_coordination +} + +fn buyer_orders_retry_card(cx: &mut Context<HomeView>) -> AnyElement { + home_card( + app_shared_text(AppTextKey::PersonalOrdersCoordinationRetryTitle), + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(home_body_text(app_shared_text( + AppTextKey::PersonalOrdersCoordinationRetryBody, + ))) + .child(action_button_primary( + "buyer-orders-retry-coordination", + app_shared_text(AppTextKey::PersonalOrdersCoordinationRetryAction), + cx.listener(|this, _, _, cx| this.retry_pending_personal_order_coordination(cx)), + cx, + )), + ) + .into_any_element() +} + fn buyer_orders_list_entry( index: usize, row: &BuyerOrdersListRow, @@ -12786,6 +12839,10 @@ fn buyer_order_place_failure_notice(error: &AppSqliteError) -> BuyerWorkspaceNot } } +fn buyer_order_coordination_notice_forces_redraw(notice: BuyerWorkspaceNotice) -> bool { + notice == BuyerWorkspaceNotice::OrderCoordinationFailed +} + fn buyer_workspace_notice_card(notice: String) -> impl IntoElement { app_surface_card(home_body_text(notice)) } @@ -12937,9 +12994,10 @@ mod tests { SettingsInventorySectionSpec, SettingsPanelViewKey, StartupHomeSurface, StartupSignerConnectState, about_conflict_action_specs, about_conflict_aggregate_text, about_conflict_detail_rows, about_conflict_review_body_key, about_manual_refresh_enabled, - about_runtime_rows, about_status_rows, app_text, buyer_orders_status_key, - farm_setup_onboarding_card_spec, farmer_home_farm_state, farmer_pack_day_available, - home_auto_focus_target, home_content_scroll_id, home_saved_farm, + about_runtime_rows, about_status_rows, app_text, + buyer_order_coordination_notice_forces_redraw, buyer_orders_retry_action_visible, + buyer_orders_status_key, farm_setup_onboarding_card_spec, farmer_home_farm_state, + farmer_pack_day_available, home_auto_focus_target, home_content_scroll_id, home_saved_farm, home_sidebar_navigation_sections, home_stage, home_window_launch_size_px, home_window_minimum_size_px, pack_day_batch_print_action_presentation, pack_day_batch_print_status_presentation, pack_day_export_action_enabled, @@ -12982,10 +13040,10 @@ mod tests { RadrootsAppRemoteSignerSessionRecord, }; use radroots_app_state::{ - AppShellProjection, FarmWorkspaceReadinessProjection, FarmWorkspaceStatus, HomeRoute, - PackDayBatchPrintProjection, PackDayBatchPrintRequest, PackDayExportProjection, - PackDayHostHandoffProjection, PackDayHostHandoffRequest, PackDayPrintProjection, - PackDayPrintRequest, + AppShellProjection, BuyerOrdersScreenProjection, FarmWorkspaceReadinessProjection, + FarmWorkspaceStatus, HomeRoute, PackDayBatchPrintProjection, PackDayBatchPrintRequest, + PackDayExportProjection, PackDayHostHandoffProjection, PackDayHostHandoffRequest, + PackDayPrintProjection, PackDayPrintRequest, }; use radroots_app_sync::{ AppSyncProjection, AppSyncRunStatus, SyncAggregateRef, SyncCheckpointStatus, SyncConflict, @@ -13101,6 +13159,25 @@ mod tests { } #[test] + fn buyer_order_coordination_failure_forces_redraw_when_notice_is_unchanged() { + assert!(buyer_order_coordination_notice_forces_redraw( + BuyerWorkspaceNotice::OrderCoordinationFailed + )); + assert!(!buyer_order_coordination_notice_forces_redraw( + BuyerWorkspaceNotice::OrderPlaceFailed + )); + } + + #[test] + fn buyer_orders_retry_action_tracks_recoverable_coordination() { + let mut orders = BuyerOrdersScreenProjection::default(); + assert!(!buyer_orders_retry_action_visible(&orders)); + + orders.has_recoverable_coordination = true; + assert!(buyer_orders_retry_action_visible(&orders)); + } + + #[test] fn buyer_browse_refresh_failure_uses_typed_visible_notice() { let (mut view, paths, home_dir) = test_home_view("buyer_notice"); block_shared_local_events_database(&paths); diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -144,6 +144,9 @@ define_app_text_keys! { PersonalOrdersRepeatDemandNotePartialSingle => "personal.orders.repeat_demand.note.partial_single", PersonalOrdersRepeatDemandNotePartialMultiple => "personal.orders.repeat_demand.note.partial_multiple", PersonalOrdersRepeatDemandNoteUnavailable => "personal.orders.repeat_demand.note.unavailable", + PersonalOrdersCoordinationRetryTitle => "personal.orders.coordination_retry.title", + PersonalOrdersCoordinationRetryBody => "personal.orders.coordination_retry.body", + PersonalOrdersCoordinationRetryAction => "personal.orders.coordination_retry.action", PersonalOrdersStatusPlaced => "personal.orders.status.placed", PersonalOrdersStatusScheduled => "personal.orders.status.scheduled", PersonalOrdersStatusReady => "personal.orders.status.ready", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -440,6 +440,18 @@ mod tests { app_text(AppTextKey::PersonalOrdersRepeatDemandNoteUnavailable), "Items from this order are currently unavailable to reorder." ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersCoordinationRetryTitle), + "Finish sharing saved orders" + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersCoordinationRetryBody), + "A saved order still needs to be shared with your order tools." + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersCoordinationRetryAction), + "Try sharing again" + ); } #[test] diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -132,6 +132,7 @@ pub struct BuyerCartScreenProjection { pub struct BuyerOrdersScreenProjection { pub list: BuyerOrdersProjection, pub detail: Option<BuyerOrderDetailProjection>, + pub has_recoverable_coordination: bool, } #[derive(Clone, Debug, Default, Eq, PartialEq)] diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -124,6 +124,9 @@ "personal.orders.repeat_demand.note.partial_single": "One item from this order is currently unavailable to reorder.", "personal.orders.repeat_demand.note.partial_multiple": "Some items from this order are currently unavailable to reorder.", "personal.orders.repeat_demand.note.unavailable": "Items from this order are currently unavailable to reorder.", + "personal.orders.coordination_retry.title": "Finish sharing saved orders", + "personal.orders.coordination_retry.body": "A saved order still needs to be shared with your order tools.", + "personal.orders.coordination_retry.action": "Try sharing again", "personal.orders.status.placed": "Placed", "personal.orders.status.scheduled": "Scheduled", "personal.orders.status.ready": "Ready",