app

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

commit d474f24f7f7c357d065bea2e3acd78e70c423692
parent 05b4faf85f15cbca170b3fe7741d03b022ea2215
Author: triesap <tyson@radroots.org>
Date:   Thu,  4 Jun 2026 20:28:37 -0700

orders: align lifecycle action vocabulary

Diffstat:
Mcrates/desktop/src/source_guards.rs | 47++++++++++++++++++++++++++++++++++++-----------
Mcrates/desktop/src/window.rs | 70+++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/i18n/src/keys.rs | 6+++---
Mcrates/i18n/src/lib.rs | 8++++++--
Mcrates/store/src/repo/orders.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/view/src/lib.rs | 23+++++++++++++----------
Mi18n/locales/en/messages.json | 6+++---
7 files changed, 182 insertions(+), 71 deletions(-)

diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -99,8 +99,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to cancel buyer order", "failed to keep buyer order", "failed to mark buyer order received", - "failed to mark order delivered", - "failed to mark order ready", + "failed to publish delivered order", + "failed to publish order ready for pickup", "failed to open existing product editor", "failed to open new product editor", "failed to acknowledge reminder", @@ -194,8 +194,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "pack-day-reveal-bundle", "pack_day.export_failed", "pack_day.route_failed", - "orders-detail-mark-completed", - "orders-detail-mark-packed", + "orders-detail-publish-delivered", + "orders-detail-publish-ready-for-pickup", "orders-filter-all", "orders-filter-completed", "orders-filter-needs-action", @@ -206,14 +206,14 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "orders-recovery-review", "orders-recovery-reopen", "orders-recovery-resolve", - "orders-row-action-mark-completed", - "orders-row-action-mark-packed", + "orders-row-action-publish-delivered", + "orders-row-action-publish-ready-for-pickup", "orders-row-action-review", "orders-row-open", "orders.detail_open_failed", "orders.filter_update_failed", - "orders.mark_delivered_failed", - "orders.ready_for_pickup_failed", + "orders.delivered_publish_failed", + "orders.ready_for_pickup_publish_failed", "orders.recovery_reopen_failed", "orders.recovery_resolve_failed", "orders.recovery_review_failed", @@ -455,7 +455,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersFilterAll", "AppTextKey::OrdersStatusNeedsAction", "AppTextKey::OrdersStatusScheduled", - "AppTextKey::OrdersStatusPacked", + "AppTextKey::OrdersStatusInHandoff", "AppTextKey::OrdersStatusCompleted", "AppTextKey::OrdersStatusRefunded", "AppTextKey::OrdersTableTitle", @@ -465,8 +465,8 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersColumnPickup", "AppTextKey::OrdersColumnAction", "AppTextKey::OrdersActionReview", - "AppTextKey::OrdersActionMarkPacked", - "AppTextKey::OrdersActionMarkCompleted", + "AppTextKey::OrdersActionReadyForPickup", + "AppTextKey::OrdersActionMarkDelivered", "AppTextKey::OrdersEmptyTitle", "AppTextKey::OrdersEmptyBody", "AppTextKey::OrdersEmptyNeedsActionTitle", @@ -810,6 +810,19 @@ const FORBIDDEN_HARDCODED_WORKFLOW_UI_LITERALS: &[&str] = &[ "Unknown", ]; +const FORBIDDEN_STALE_SELLER_LIFECYCLE_WINDOW_PATTERNS: &[&str] = &[ + concat!("orders-detail-", "mark-packed"), + concat!("orders-detail-", "mark-completed"), + concat!("orders-row-action-", "mark-packed"), + concat!("orders-row-action-", "mark-completed"), + concat!("orders.", "mark_delivered_failed"), + concat!("OrderPrimaryAction::", "MarkPacked"), + concat!("OrderPrimaryAction::", "MarkCompleted"), + concat!("AppTextKey::", "OrdersStatus", "Packed"), + concat!("AppTextKey::", "OrdersAction", "MarkPacked"), + concat!("AppTextKey::", "OrdersAction", "MarkCompleted"), +]; + const FORBIDDEN_PAYMENT_DEFERRAL_COPY_PATTERNS: &[&str] = &[ "payments are deferred", "payment is deferred", @@ -900,6 +913,18 @@ fn desktop_window_source_does_not_reintroduce_removed_ui_helper_families() { } #[test] +fn desktop_window_source_uses_publish_lifecycle_action_identifiers() { + let source = include_str!("window.rs"); + + for pattern in FORBIDDEN_STALE_SELLER_LIFECYCLE_WINDOW_PATTERNS { + assert!( + !source.contains(pattern), + "window.rs still contains stale seller lifecycle action pattern `{pattern}`" + ); + } +} + +#[test] fn desktop_window_source_does_not_use_about_placeholder_copy() { let source = include_str!("window.rs"); diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -298,8 +298,8 @@ enum HomeAutoFocusTarget { ProductsStockInput, ProductEditorTitleInput, OrdersRowOpenFirst, - OrdersDetailMarkPacked, - OrdersDetailMarkCompleted, + OrdersDetailPublishReadyForPickup, + OrdersDetailPublishDelivered, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -477,11 +477,11 @@ impl HomeView { HomeAutoFocusTarget::OrdersRowOpenFirst => { focus_button(window, ("orders-row-open", 0_usize), cx); } - HomeAutoFocusTarget::OrdersDetailMarkPacked => { - focus_button(window, "orders-detail-mark-packed", cx); + HomeAutoFocusTarget::OrdersDetailPublishReadyForPickup => { + focus_button(window, "orders-detail-publish-ready-for-pickup", cx); } - HomeAutoFocusTarget::OrdersDetailMarkCompleted => { - focus_button(window, "orders-detail-mark-completed", cx); + HomeAutoFocusTarget::OrdersDetailPublishDelivered => { + focus_button(window, "orders-detail-publish-delivered", cx); } } } @@ -2106,10 +2106,10 @@ impl HomeView { Err(runtime_error) => { error!( target: "orders", - event = "orders.ready_for_pickup_failed", + event = "orders.ready_for_pickup_publish_failed", error = %runtime_error, order_id = %order_id, - "failed to mark order ready" + "failed to publish order ready for pickup" ); } } @@ -2122,10 +2122,10 @@ impl HomeView { Err(runtime_error) => { error!( target: "orders", - event = "orders.mark_delivered_failed", + event = "orders.delivered_publish_failed", error = %runtime_error, order_id = %order_id, - "failed to mark order delivered" + "failed to publish delivered order" ); } } @@ -3490,7 +3490,7 @@ impl HomeView { summary.scheduled_orders, )) .child(home_summary_metric( - AppTextKey::OrdersStatusPacked, + AppTextKey::OrdersStatusInHandoff, summary.packed_orders, )), ) @@ -3526,7 +3526,7 @@ impl HomeView { )) .child(choice_button( "orders-filter-packed", - app_shared_text(AppTextKey::OrdersStatusPacked), + app_shared_text(AppTextKey::OrdersStatusInHandoff), projection.query.filter == OrdersFilter::Packed, cx.listener(|this, _, _, cx| { this.select_orders_filter(OrdersFilter::Packed, cx) @@ -4174,10 +4174,10 @@ impl HomeView { cx: &mut Context<Self>, ) -> AnyElement { let primary_action = match detail.primary_action { - Some(OrderPrimaryAction::MarkPacked) => Some( + Some(OrderPrimaryAction::PublishReadyForPickup) => Some( action_button_primary( - "orders-detail-mark-packed", - app_shared_text(AppTextKey::OrdersActionMarkPacked), + "orders-detail-publish-ready-for-pickup", + app_shared_text(AppTextKey::OrdersActionReadyForPickup), cx.listener({ let order_id = detail.order_id; move |this, _, _, cx| this.publish_order_ready_for_pickup(order_id, cx) @@ -4186,10 +4186,10 @@ impl HomeView { ) .into_any_element(), ), - Some(OrderPrimaryAction::MarkCompleted) => Some( + Some(OrderPrimaryAction::PublishDelivered) => Some( action_button_primary( - "orders-detail-mark-completed", - app_shared_text(AppTextKey::OrdersActionMarkCompleted), + "orders-detail-publish-delivered", + app_shared_text(AppTextKey::OrdersActionMarkDelivered), cx.listener({ let order_id = detail.order_id; move |this, _, _, cx| this.publish_order_delivered(order_id, cx) @@ -7679,11 +7679,11 @@ fn farmer_auto_focus_target( FarmerSection::Orders if farmer_products_available(runtime) => { if let Some(detail) = runtime.orders_projection.detail.as_ref() { match detail.primary_action { - Some(OrderPrimaryAction::MarkPacked) => { - Some(HomeAutoFocusTarget::OrdersDetailMarkPacked) + Some(OrderPrimaryAction::PublishReadyForPickup) => { + Some(HomeAutoFocusTarget::OrdersDetailPublishReadyForPickup) } - Some(OrderPrimaryAction::MarkCompleted) => { - Some(HomeAutoFocusTarget::OrdersDetailMarkCompleted) + Some(OrderPrimaryAction::PublishDelivered) => { + Some(HomeAutoFocusTarget::OrdersDetailPublishDelivered) } Some(OrderPrimaryAction::Review) | None if !runtime.orders_projection.list.rows.is_empty() => @@ -10395,8 +10395,8 @@ fn orders_table_action( index: usize, row: &OrdersListRow, on_review: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - on_mark_packed: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - on_mark_completed: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_publish_ready_for_pickup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_publish_delivered: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> AnyElement { match row.primary_action { @@ -10407,17 +10407,17 @@ fn orders_table_action( cx, ) .into_any_element(), - Some(OrderPrimaryAction::MarkPacked) => action_button_compact( - ("orders-row-action-mark-packed", index), - app_shared_text(AppTextKey::OrdersActionMarkPacked), - on_mark_packed, + Some(OrderPrimaryAction::PublishReadyForPickup) => action_button_compact( + ("orders-row-action-publish-ready-for-pickup", index), + app_shared_text(AppTextKey::OrdersActionReadyForPickup), + on_publish_ready_for_pickup, cx, ) .into_any_element(), - Some(OrderPrimaryAction::MarkCompleted) => action_button_compact( - ("orders-row-action-mark-completed", index), - app_shared_text(AppTextKey::OrdersActionMarkCompleted), - on_mark_completed, + Some(OrderPrimaryAction::PublishDelivered) => action_button_compact( + ("orders-row-action-publish-delivered", index), + app_shared_text(AppTextKey::OrdersActionMarkDelivered), + on_publish_delivered, cx, ) .into_any_element(), @@ -14164,7 +14164,7 @@ mod tests { farmer_order_id, OrderStatus::Scheduled, ), - primary_action: Some(OrderPrimaryAction::MarkPacked), + primary_action: Some(OrderPrimaryAction::PublishReadyForPickup), }]; orders.orders_projection.detail = Some(OrderDetailProjection { order_id: farmer_order_id, @@ -14182,12 +14182,12 @@ mod tests { farmer_order_id, OrderStatus::Scheduled, ), - primary_action: Some(OrderPrimaryAction::MarkPacked), + primary_action: Some(OrderPrimaryAction::PublishReadyForPickup), recoveries: Vec::new(), }); assert_eq!( home_auto_focus_target(&orders, HomeAutoFocusState::default()), - Some(HomeAutoFocusTarget::OrdersDetailMarkPacked) + Some(HomeAutoFocusTarget::OrdersDetailPublishReadyForPickup) ); } diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -192,7 +192,7 @@ define_app_text_keys! { OrdersFilterAll => "orders.filter.all", OrdersStatusNeedsAction => "orders.status.needs_action", OrdersStatusScheduled => "orders.status.scheduled", - OrdersStatusPacked => "orders.status.packed", + OrdersStatusInHandoff => "orders.status.in_handoff", OrdersStatusCompleted => "orders.status.completed", OrdersStatusDeclined => "orders.status.declined", OrdersStatusRefunded => "orders.status.refunded", @@ -203,8 +203,8 @@ define_app_text_keys! { OrdersColumnPickup => "orders.column.pickup", OrdersColumnAction => "orders.column.action", OrdersActionReview => "orders.action.review", - OrdersActionMarkPacked => "orders.action.mark_packed", - OrdersActionMarkCompleted => "orders.action.mark_completed", + OrdersActionReadyForPickup => "orders.action.ready_for_pickup", + OrdersActionMarkDelivered => "orders.action.mark_delivered", OrdersEmptyTitle => "orders.empty.title", OrdersEmptyBody => "orders.empty.body", OrdersEmptyNeedsActionTitle => "orders.empty.needs_action.title", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -339,9 +339,13 @@ mod tests { "Needs action" ); assert_eq!(app_text(AppTextKey::OrdersStatusDeclined), "Declined"); - assert_eq!(app_text(AppTextKey::OrdersActionMarkPacked), "Mark packed"); + assert_eq!(app_text(AppTextKey::OrdersStatusInHandoff), "In handoff"); assert_eq!( - app_text(AppTextKey::OrdersActionMarkCompleted), + app_text(AppTextKey::OrdersActionReadyForPickup), + "Ready for pickup" + ); + assert_eq!( + app_text(AppTextKey::OrdersActionMarkDelivered), "Mark delivered" ); assert_eq!(app_text(AppTextKey::OrdersDetailTitle), "Order detail"); diff --git a/crates/store/src/repo/orders.rs b/crates/store/src/repo/orders.rs @@ -7,7 +7,7 @@ use radroots_app_view::{ PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, - PackDayScreenQueryState, ProductId, TradeWorkflowProjection, + PackDayScreenQueryState, ProductId, TradeFulfillmentStatus, TradeWorkflowProjection, }; use rusqlite::{Connection, OptionalExtension, params}; @@ -173,8 +173,8 @@ impl<'a> AppOrdersRepository<'a> { items, economics, payment, + primary_action: primary_action_for_order(status, &workflow), workflow, - primary_action: primary_action_for_status(status), recoveries: Vec::new(), }) }, @@ -1161,8 +1161,8 @@ impl OrderRecord { fulfillment_window_label: self.fulfillment_window_label, pickup_location_label: self.pickup_location_label, status: self.status, + primary_action: primary_action_for_order(self.status, &self.workflow), workflow: self.workflow, - primary_action: primary_action_for_status(self.status), } } } @@ -1185,11 +1185,21 @@ fn summarize_orders(records: &[OrderRecord]) -> OrdersListSummary { summary } -fn primary_action_for_status(status: OrderStatus) -> Option<OrderPrimaryAction> { +fn primary_action_for_order( + status: OrderStatus, + workflow: &TradeWorkflowProjection, +) -> Option<OrderPrimaryAction> { match status { OrderStatus::NeedsAction => Some(OrderPrimaryAction::Review), - OrderStatus::Scheduled => Some(OrderPrimaryAction::MarkPacked), - OrderStatus::Packed => Some(OrderPrimaryAction::MarkCompleted), + OrderStatus::Scheduled | OrderStatus::Packed => match workflow.fulfillment { + None | Some(TradeFulfillmentStatus::Confirmed | TradeFulfillmentStatus::Preparing) => { + Some(OrderPrimaryAction::PublishReadyForPickup) + } + Some( + TradeFulfillmentStatus::ReadyForPickup | TradeFulfillmentStatus::OutForDelivery, + ) => Some(OrderPrimaryAction::PublishDelivered), + Some(TradeFulfillmentStatus::Delivered | TradeFulfillmentStatus::Cancelled) => None, + }, OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => None, } } @@ -1499,7 +1509,10 @@ mod tests { assert_eq!(detail.economics.total_minor_units, Some(1950)); assert_eq!(detail.economics.currency_code.as_deref(), Some("USD")); assert_eq!(detail.payment, TradePaymentDisplayStatus::NotRecorded); - assert_eq!(detail.primary_action, Some(OrderPrimaryAction::MarkPacked)); + assert_eq!( + detail.primary_action, + Some(OrderPrimaryAction::PublishReadyForPickup) + ); } #[test] @@ -1653,6 +1666,72 @@ mod tests { assert_eq!(workflow.economics.currency_code.as_deref(), Some("USD")); assert_eq!(detail.payment, TradePaymentDisplayStatus::Recorded); assert_eq!(detail.workflow, *workflow); + assert_eq!( + list.rows[0].primary_action, + Some(OrderPrimaryAction::PublishDelivered) + ); + assert_eq!( + detail.primary_action, + Some(OrderPrimaryAction::PublishDelivered) + ); + } + + #[test] + fn seller_delivered_workflow_exposes_no_duplicate_primary_action() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + let farm_id = FarmId::new(); + let order_id = OrderId::new(); + + insert_farm( + connection, + farm_id, + "Willow farm", + "ready", + "2026-04-17T08:00:00Z", + ); + insert_order( + connection, + order_id, + farm_id, + None, + "R-101", + "Casey", + "packed", + "2026-04-17T10:00:00Z", + ); + set_order_workflow_display_projection( + connection, + order_id, + "confirmed", + Some("delivered"), + "reserved", + "not_recorded", + "local_events", + Some("seller-delivered-event"), + ); + + let list = store + .load_orders_list( + farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::Packed, + fulfillment_window_id: None, + }, + ) + .expect("seller list should load"); + let detail = store + .load_order_detail(farm_id, order_id) + .expect("seller detail should load") + .expect("seller detail should exist"); + + assert_eq!(list.rows[0].status, OrderStatus::Packed); + assert_eq!( + list.rows[0].workflow.fulfillment, + Some(TradeFulfillmentStatus::Delivered) + ); + assert_eq!(list.rows[0].primary_action, None); + assert_eq!(detail.primary_action, None); } #[test] diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -1648,16 +1648,16 @@ pub struct OrdersScreenQueryState { #[serde(rename_all = "snake_case")] pub enum OrderPrimaryAction { Review, - MarkPacked, - MarkCompleted, + PublishReadyForPickup, + PublishDelivered, } impl OrderPrimaryAction { pub const fn storage_key(self) -> &'static str { match self { Self::Review => "review", - Self::MarkPacked => "mark_packed", - Self::MarkCompleted => "mark_completed", + Self::PublishReadyForPickup => "publish_ready_for_pickup", + Self::PublishDelivered => "publish_delivered", } } } @@ -2825,10 +2825,13 @@ mod tests { assert_eq!(OrdersFilter::Refunded.storage_key(), "refunded"); assert_eq!(OrderPrimaryAction::Review.storage_key(), "review"); - assert_eq!(OrderPrimaryAction::MarkPacked.storage_key(), "mark_packed"); assert_eq!( - OrderPrimaryAction::MarkCompleted.storage_key(), - "mark_completed" + OrderPrimaryAction::PublishReadyForPickup.storage_key(), + "publish_ready_for_pickup" + ); + assert_eq!( + OrderPrimaryAction::PublishDelivered.storage_key(), + "publish_delivered" ); } @@ -3485,7 +3488,7 @@ mod tests { order_id, OrderStatus::Scheduled, ), - primary_action: Some(OrderPrimaryAction::MarkPacked), + primary_action: Some(OrderPrimaryAction::PublishReadyForPickup), }], }; let order_detail = OrderDetailProjection { @@ -3511,7 +3514,7 @@ mod tests { payment: order_payment, workflow: TradeWorkflowProjection::from_order_status(order_id, OrderStatus::Scheduled) .with_economics_and_payment(order_economics, order_payment), - primary_action: Some(OrderPrimaryAction::MarkPacked), + primary_action: Some(OrderPrimaryAction::PublishReadyForPickup), recoveries: Vec::new(), }; let pack_day = PackDayProjection { @@ -3541,7 +3544,7 @@ mod tests { assert!(!orders_list.is_empty()); assert_eq!( orders_list.rows[0].primary_action, - Some(OrderPrimaryAction::MarkPacked) + Some(OrderPrimaryAction::PublishReadyForPickup) ); assert_eq!( orders_list.rows[0].workflow.agreement, diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -172,7 +172,7 @@ "orders.filter.all": "All", "orders.status.needs_action": "Needs action", "orders.status.scheduled": "Scheduled", - "orders.status.packed": "Packed", + "orders.status.in_handoff": "In handoff", "orders.status.completed": "Completed", "orders.status.declined": "Declined", "orders.status.refunded": "Refunded", @@ -183,8 +183,8 @@ "orders.column.pickup": "Pickup", "orders.column.action": "Action", "orders.action.review": "Review", - "orders.action.mark_packed": "Mark packed", - "orders.action.mark_completed": "Mark delivered", + "orders.action.ready_for_pickup": "Ready for pickup", + "orders.action.mark_delivered": "Mark delivered", "orders.empty.title": "No orders yet", "orders.empty.body": "Orders will appear here when customers place them.", "orders.empty.needs_action.title": "Nothing needs action",