app

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

commit adc217b5c27101a6fdedaa28e7c4f1f2591bfb10
parent fccfdb952a2f2f7afdcba47484271ea7e8dc971f
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Apr 2026 23:59:06 +0000

pack_day: land contextual pack day workspace

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 52++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/launchers/desktop/src/source_guards.rs | 12++++++++++++
Mcrates/launchers/desktop/src/window.rs | 290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/shared/i18n/src/keys.rs | 7+++++++
Mcrates/shared/i18n/src/lib.rs | 22++++++++++++++++++++++
Mi18n/locales/en/messages.json | 7+++++++
6 files changed, 371 insertions(+), 19 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -684,7 +684,7 @@ impl DesktopAppRuntimeState { section_changed || detail_changed || editor_changed } - FarmerSection::PackDay if self.has_saved_farm() => { + FarmerSection::PackDay if self.has_saved_farm() && self.has_pack_day_context() => { let section_changed = self.state_store .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( @@ -870,9 +870,13 @@ impl DesktopAppRuntimeState { return Ok(false); } - let query_changed = self.replace_pack_day_query(PackDayScreenQueryState { + let query = PackDayScreenQueryState { fulfillment_window_id, - })?; + }; + let query_changed = self.replace_pack_day_query(query)?; + if !self.has_pack_day_context() { + return Ok(false); + } let section_changed = self .state_store .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( @@ -1336,6 +1340,14 @@ impl DesktopAppRuntimeState { self.state_store.farm_setup_projection().has_saved_farm() } + fn has_pack_day_context(&self) -> bool { + self.state_store + .pack_day_projection() + .projection + .fulfillment_window + .is_some() + } + 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, @@ -1440,9 +1452,12 @@ impl DesktopAppRuntimeState { 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 | FarmerSection::Orders | FarmerSection::PackDay, - ) => !self.has_saved_farm(), + ShellSection::Farmer(FarmerSection::Products | FarmerSection::Orders) => { + !self.has_saved_farm() + } + ShellSection::Farmer(FarmerSection::PackDay) => { + !self.has_saved_farm() || !self.has_pack_day_context() + } ShellSection::Farmer(FarmerSection::Farm) => true, ShellSection::Home | ShellSection::Settings(_) => false, }; @@ -2290,6 +2305,31 @@ mod tests { } #[test] + fn pack_day_stays_blocked_without_a_window_context() { + let runtime = memory_runtime(); + let _ = provision_ready_farmer_account(&runtime); + + assert!(!runtime.select_farmer_section(FarmerSection::PackDay)); + assert!( + !runtime + .open_pack_day(None) + .expect("pack day route should stay blocked") + ); + assert_eq!( + runtime.summary().shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Today) + ); + assert!( + runtime + .summary() + .pack_day_projection + .projection + .fulfillment_window + .is_none() + ); + } + + #[test] fn runtime_routes_between_farmer_home_and_products_through_explicit_methods() { let runtime = memory_runtime(); diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -40,6 +40,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to open existing product editor", "failed to open new product editor", "failed to open order detail", + "failed to route into pack day view", "failed to route into orders view", "failed to save farm settings projection", "failed to save product editor draft", @@ -60,9 +61,11 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "home-farm-setup-start", "home-generate-key", "home-nav-orders", + "home-nav-pack-day", "home-nav-products", "home-nav-today", "home-orders-scroll", + "home-pack-day-scroll", "home-signer-back", "home-signer-source-input", "home-today-open-orders", @@ -75,6 +78,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "none", "npub1", "orders", + "pack_day", + "pack_day.route_failed", "orders-detail-mark-completed", "orders-detail-mark-packed", "orders-filter-all", @@ -227,6 +232,13 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersDetailStatusLabel", "AppTextKey::OrdersDetailWindowLabel", "AppTextKey::OrdersDetailPickupLabel", + "AppTextKey::PackDayTitle", + "AppTextKey::PackDayWindowSummaryTitle", + "AppTextKey::PackDayTotalsTitle", + "AppTextKey::PackDayPackListTitle", + "AppTextKey::PackDayPickupRosterTitle", + "AppTextKey::PackDayEmptyTitle", + "AppTextKey::PackDayEmptyBody", "AppTextKey::ProductsTitle", "AppTextKey::ProductsFiltersTitle", "AppTextKey::ProductsSearchPlaceholder", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -16,10 +16,11 @@ use radroots_app_models::{ FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary, LoggedOutStartupPhase, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderListRow, - OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListRow, PickupLocationId, - PickupLocationRecord, ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, - ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, - ShellSection, TodayAgendaProjection, TodaySetupTaskKind, + OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListRow, PackDayPackListRow, + PackDayProductTotalRow, PackDayRosterRow, PickupLocationId, PickupLocationRecord, + ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPublishBlocker, + ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection, + TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -951,6 +952,29 @@ impl HomeView { } } + fn open_pack_day( + &mut self, + fulfillment_window_id: Option<FulfillmentWindowId>, + cx: &mut Context<Self>, + ) { + match self.runtime.open_pack_day(fulfillment_window_id) { + Ok(true) => { + self.products_stock_editor = None; + self.product_editor_form = None; + cx.notify(); + } + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "pack_day", + event = "pack_day.route_failed", + error = %runtime_error, + "failed to route into pack day view" + ); + } + } + } + fn select_orders_filter(&mut self, filter: OrdersFilter, cx: &mut Context<Self>) { match self.runtime.select_orders_filter(filter) { Ok(true) => cx.notify(), @@ -1347,6 +1371,9 @@ impl HomeView { FarmerSection::Orders if farmer_products_available(runtime) => { self.render_orders_content(runtime, cx) } + FarmerSection::PackDay if farmer_pack_day_available(runtime) => { + self.render_pack_day_content(runtime, cx) + } FarmerSection::Today | FarmerSection::Products | FarmerSection::Orders @@ -1390,6 +1417,7 @@ impl HomeView { this.select_farmer_section(FarmerSection::Products, cx) }), cx.listener(|this, _, _, cx| this.open_orders(cx)), + cx.listener(|this, _, _, cx| this.open_pack_day(None, cx)), cx, ) .into_any_element(), @@ -1623,6 +1651,44 @@ impl HomeView { .into_any_element() } + fn render_pack_day_content( + &mut self, + runtime: &DesktopAppRuntimeSummary, + _: &mut Context<Self>, + ) -> AnyElement { + let projection = &runtime.pack_day_projection.projection; + let Some(fulfillment_window) = projection.fulfillment_window.as_ref() else { + return home_empty_state_card( + AppTextKey::PackDayEmptyTitle, + AppTextKey::PackDayEmptyBody, + ) + .into_any_element(); + }; + + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) + .mx_auto() + .child(pack_day_title_row(runtime)) + .child(pack_day_window_summary_card(fulfillment_window)) + .when(!projection.totals_by_product.is_empty(), |this| { + this.child(pack_day_totals_card(&projection.totals_by_product)) + }) + .when(!projection.pack_list.is_empty(), |this| { + this.child(pack_day_pack_list_card(&projection.pack_list)) + }) + .when(!projection.pickup_roster.is_empty(), |this| { + this.child(pack_day_pickup_roster_card(&projection.pickup_roster)) + }) + .when(projection.is_empty(), |this| { + this.child(home_empty_state_card( + AppTextKey::PackDayEmptyTitle, + AppTextKey::PackDayEmptyBody, + )) + }) + .into_any_element() + } + fn render_products_table_card( &mut self, rows: &[ProductsListRow], @@ -4912,10 +4978,12 @@ fn home_sidebar( on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_select_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_select_orders: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_select_pack_day: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> impl IntoElement { let selected_section = selected_farmer_section(runtime); let workspace_available = farmer_products_available(runtime); + let pack_day_available = farmer_pack_day_available(runtime); app_surface_sidebar( div() @@ -4954,6 +5022,15 @@ fn home_sidebar( on_select_orders, cx, )) + }) + .when(pack_day_available, |this| { + this.child(home_sidebar_nav_button( + "home-nav-pack-day", + AppTextKey::PackDayTitle, + selected_section == FarmerSection::PackDay, + on_select_pack_day, + cx, + )) }), ) .child( @@ -5202,11 +5279,20 @@ fn farmer_products_available(runtime: &DesktopAppRuntimeSummary) -> bool { runtime.farm_setup_projection.has_saved_farm() } +fn farmer_pack_day_available(runtime: &DesktopAppRuntimeSummary) -> bool { + runtime + .pack_day_projection + .projection + .fulfillment_window + .is_some() +} + fn home_content_scroll_id(section: FarmerSection) -> &'static str { match section { FarmerSection::Products => "home-products-scroll", FarmerSection::Orders => "home-orders-scroll", - FarmerSection::Today | FarmerSection::PackDay | FarmerSection::Farm => "home-today-scroll", + FarmerSection::PackDay => "home-pack-day-scroll", + FarmerSection::Today | FarmerSection::Farm => "home-today-scroll", } } @@ -5669,6 +5755,149 @@ fn orders_status_color(status: OrderStatus) -> u32 { } } +fn pack_day_title_row(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { + app_stack_v(4.0) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) + .font_weight(gpui::FontWeight::BOLD) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(app_shared_text(AppTextKey::PackDayTitle)), + ) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .when_some(home_saved_farm(runtime), |this, farm| { + this.child(farm.display_name.clone()) + }), + ) +} + +fn pack_day_window_summary_card(fulfillment_window: &FulfillmentWindowSummary) -> impl IntoElement { + home_card( + app_shared_text(AppTextKey::PackDayWindowSummaryTitle), + label_value_list([ + LabelValueRow::new( + app_shared_text(AppTextKey::HomeTodayWindowStartsLabel), + fulfillment_window.starts_at.clone(), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::HomeTodayWindowEndsLabel), + fulfillment_window.ends_at.clone(), + ), + ]), + ) +} + +fn pack_day_totals_card(rows: &[PackDayProductTotalRow]) -> impl IntoElement { + home_card( + app_shared_text(AppTextKey::PackDayTotalsTitle), + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .children( + rows.iter() + .map(pack_day_product_total_row) + .collect::<Vec<_>>(), + ), + ) +} + +fn pack_day_pack_list_card(rows: &[PackDayPackListRow]) -> impl IntoElement { + home_card( + app_shared_text(AppTextKey::PackDayPackListTitle), + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .children(rows.iter().map(pack_day_pack_list_row).collect::<Vec<_>>()), + ) +} + +fn pack_day_pickup_roster_card(rows: &[PackDayRosterRow]) -> impl IntoElement { + home_card( + app_shared_text(AppTextKey::PackDayPickupRosterTitle), + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .children(rows.iter().map(pack_day_roster_row).collect::<Vec<_>>()), + ) +} + +fn pack_day_product_total_row(row: &PackDayProductTotalRow) -> AnyElement { + pack_day_label_value_row(row.title.as_str(), row.quantity_display.as_str()) +} + +fn pack_day_pack_list_row(row: &PackDayPackListRow) -> AnyElement { + pack_day_label_value_row(row.title.as_str(), row.quantity_display.as_str()) +} + +fn pack_day_roster_row(row: &PackDayRosterRow) -> AnyElement { + div() + .w_full() + .min_w_0() + .flex() + .items_center() + .justify_between() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(row.order_number.clone()), + ) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(row.customer_display_name.clone()), + ), + ) + .into_any_element() +} + +fn pack_day_label_value_row(label: &str, value: &str) -> AnyElement { + div() + .w_full() + .min_w_0() + .flex() + .items_center() + .justify_between() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(label.to_owned()), + ) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(value.to_owned()), + ) + .into_any_element() +} + fn products_empty_state_card(filter: ProductsFilter) -> impl IntoElement { let (title_key, body_key) = if filter == ProductsFilter::NeedAttention { ( @@ -7271,19 +7500,20 @@ mod tests { AppTextKey, FarmerHomeFarmState, SETTINGS_FARM_PANEL_SECTIONS, SETTINGS_NAVIGATION_ORDER, SETTINGS_OPERATIONS_PANEL_SECTIONS, SettingsInventorySectionSpec, SettingsPanelViewKey, StartupHomeSurface, StartupSignerConnectState, farm_setup_onboarding_card_spec, - farmer_home_farm_state, home_saved_farm, home_window_launch_size_px, - home_window_minimum_size_px, parse_optional_product_editor_stock_input, - parse_product_editor_price_input, product_display_title, startup_home_surface, - startup_signer_preview_summary, startup_signer_preview_summary_for_connect_state, - startup_signer_source_input_is_editable, startup_signer_status_spec, - startup_signer_transport_failure_requires_notice, + farmer_home_farm_state, farmer_pack_day_available, home_content_scroll_id, home_saved_farm, + home_window_launch_size_px, home_window_minimum_size_px, + parse_optional_product_editor_stock_input, parse_product_editor_price_input, + product_display_title, startup_home_surface, startup_signer_preview_summary, + startup_signer_preview_summary_for_connect_state, startup_signer_source_input_is_editable, + startup_signer_status_spec, startup_signer_transport_failure_requires_notice, }; use crate::runtime::DesktopAppRuntimeSummary; use radroots_app_models::SettingsAccountProjection; use radroots_app_models::{ AppStartupGate, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, - FarmSetupProjection, FarmSummary, LoggedOutStartupPhase, LoggedOutStartupProjection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, + FulfillmentWindowSummary, LoggedOutStartupPhase, LoggedOutStartupProjection, + PackDayProjection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, @@ -7515,6 +7745,40 @@ mod tests { } #[test] + fn pack_day_availability_tracks_the_contextual_window_projection() { + let farm_id = FarmId::new(); + let mut runtime = summary( + HomeRoute::Today, + TodayAgendaProjection::default(), + FarmSetupProjection::from_saved_farm(FarmSummary { + farm_id, + display_name: String::new(), + readiness: FarmReadiness::Ready, + }), + ); + + assert!(!farmer_pack_day_available(&runtime)); + assert_eq!( + home_content_scroll_id(FarmerSection::PackDay), + "home-pack-day-scroll" + ); + + runtime.pack_day_projection.projection = PackDayProjection { + fulfillment_window: Some(FulfillmentWindowSummary { + fulfillment_window_id: FulfillmentWindowId::new(), + farm_id, + starts_at: String::new(), + ends_at: String::new(), + }), + totals_by_product: Vec::new(), + pack_list: Vec::new(), + pickup_roster: Vec::new(), + }; + + assert!(farmer_pack_day_available(&runtime)); + } + + #[test] fn saved_farm_falls_back_to_local_projection_when_today_is_empty() { let saved_farm = FarmSummary { farm_id: FarmId::new(), diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -120,6 +120,13 @@ define_app_text_keys! { OrdersDetailStatusLabel => "orders.detail.status.label", OrdersDetailWindowLabel => "orders.detail.window.label", OrdersDetailPickupLabel => "orders.detail.pickup.label", + PackDayTitle => "pack_day.title", + PackDayWindowSummaryTitle => "pack_day.window_summary.title", + PackDayTotalsTitle => "pack_day.totals.title", + PackDayPackListTitle => "pack_day.pack_list.title", + PackDayPickupRosterTitle => "pack_day.pickup_roster.title", + PackDayEmptyTitle => "pack_day.empty.title", + PackDayEmptyBody => "pack_day.empty.body", ProductsTitle => "products.title", ProductsFiltersTitle => "products.filters.title", ProductsSearchPlaceholder => "products.search.placeholder", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -188,6 +188,28 @@ mod tests { } #[test] + fn english_pack_day_copy_matches_the_contextual_execution_contract() { + assert_eq!(app_text(AppTextKey::PackDayTitle), "Pack day"); + assert_eq!( + app_text(AppTextKey::PackDayWindowSummaryTitle), + "Window summary" + ); + assert_eq!( + app_text(AppTextKey::PackDayTotalsTitle), + "Totals by product" + ); + assert_eq!(app_text(AppTextKey::PackDayPackListTitle), "Pack list"); + assert_eq!( + app_text(AppTextKey::PackDayPickupRosterTitle), + "Pickup roster" + ); + assert_eq!( + app_text(AppTextKey::PackDayEmptyTitle), + "Nothing to pack yet" + ); + } + + #[test] fn english_farm_rules_host_copy_matches_the_frozen_utility_window_inventory() { assert_eq!(app_text(AppTextKey::SettingsNavFarm), "Farm"); assert_eq!( diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -99,6 +99,13 @@ "orders.detail.status.label": "Status", "orders.detail.window.label": "Fulfillment window", "orders.detail.pickup.label": "Pickup location", + "pack_day.title": "Pack day", + "pack_day.window_summary.title": "Window summary", + "pack_day.totals.title": "Totals by product", + "pack_day.pack_list.title": "Pack list", + "pack_day.pickup_roster.title": "Pickup roster", + "pack_day.empty.title": "Nothing to pack yet", + "pack_day.empty.body": "Orders for this window will appear here when they are ready to pack.", "products.title": "Products", "products.filters.title": "View", "products.search.placeholder": "Search products",