app

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

commit 3fc5e55c76888ed0a85a7997a6414e02b660b490
parent c7c8e272a54891d9812494158ee81064f318423b
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Apr 2026 01:08:36 +0000

state: wire farm readiness through shell and products

Diffstat:
Mcrates/launchers/desktop/src/app.rs | 163+++++++++++++++++++++++++------------------------------------------------------
Mcrates/launchers/desktop/src/runtime.rs | 83++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/launchers/desktop/src/window.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/shared/i18n/src/keys.rs | 9+++++++++
Mcrates/shared/models/src/lib.rs | 14++++++++++++++
Mcrates/shared/state/src/lib.rs | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mi18n/locales/en/messages.json | 9+++++++++
7 files changed, 504 insertions(+), 175 deletions(-)

diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -164,7 +164,7 @@ mod tests { AppStartupGate, LoggedOutStartupProjection, SettingsAccountProjection, TodayAgendaProjection, }; - use radroots_app_state::{AppShellProjection, HomeRoute}; + use radroots_app_state::{AppShellProjection, FarmWorkspaceReadinessProjection, HomeRoute}; use tracing::{ Event, Level, Subscriber, field::{Field, Visit}, @@ -256,23 +256,36 @@ mod tests { ) } - #[test] - fn degraded_runtime_emits_launch_and_degraded_events() { - let events = Arc::new(Mutex::new(Vec::new())); - let subscriber = tracing_subscriber::registry().with(CaptureLayer { - events: Arc::clone(&events), - }); - let summary = DesktopAppRuntimeSummary { + fn summary_with_gate( + startup_gate: AppStartupGate, + home_route: HomeRoute, + startup_issue: Option<&str>, + ) -> DesktopAppRuntimeSummary { + DesktopAppRuntimeSummary { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::SetupRequired, - home_route: HomeRoute::SetupRequired, + startup_gate, + home_route, farm_setup_projection: Default::default(), + farm_readiness_projection: FarmWorkspaceReadinessProjection::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: Some("desktop runtime roots require HOME for macos".to_owned()), - }; + startup_issue: startup_issue.map(str::to_owned), + } + } + + #[test] + fn degraded_runtime_emits_launch_and_degraded_events() { + let events = Arc::new(Mutex::new(Vec::new())); + let subscriber = tracing_subscriber::registry().with(CaptureLayer { + events: Arc::clone(&events), + }); + let summary = summary_with_gate( + AppStartupGate::SetupRequired, + HomeRoute::SetupRequired, + Some("desktop runtime roots require HOME for macos"), + ); tracing::subscriber::with_default(subscriber, || { emit_runtime_events(&test_snapshot(), &summary); @@ -297,28 +310,12 @@ mod tests { #[test] fn blocked_and_setup_runtime_target_the_home_window() { - let blocked = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::Blocked, - home_route: HomeRoute::Blocked, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: None, - }; - let setup = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::SetupRequired, - home_route: HomeRoute::SetupRequired, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: None, - }; + let blocked = summary_with_gate(AppStartupGate::Blocked, HomeRoute::Blocked, None); + let setup = summary_with_gate( + AppStartupGate::SetupRequired, + HomeRoute::SetupRequired, + None, + ); assert_eq!(primary_window_target(&blocked), PrimaryWindowTarget::Home); assert_eq!(primary_window_target(&setup), PrimaryWindowTarget::Home); @@ -326,28 +323,9 @@ mod tests { #[test] fn ready_runtime_targets_the_home_window() { - let personal = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::Personal, - home_route: HomeRoute::Personal, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: None, - }; - let farmer = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::Farmer, - home_route: HomeRoute::FarmSetupOnboarding, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: None, - }; + let personal = summary_with_gate(AppStartupGate::Personal, HomeRoute::Personal, None); + let farmer = + summary_with_gate(AppStartupGate::Farmer, HomeRoute::FarmSetupOnboarding, None); assert_eq!(primary_window_target(&personal), PrimaryWindowTarget::Home); assert_eq!(primary_window_target(&farmer), PrimaryWindowTarget::Home); @@ -355,67 +333,30 @@ mod tests { #[test] fn degraded_runtime_targets_the_home_window() { - let degraded = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::Personal, - home_route: HomeRoute::Personal, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: Some("runtime unavailable".to_owned()), - }; + let degraded = summary_with_gate( + AppStartupGate::Personal, + HomeRoute::Personal, + Some("runtime unavailable"), + ); assert_eq!(primary_window_target(&degraded), PrimaryWindowTarget::Home); } #[test] fn home_stage_tracks_setup_personal_and_farmer_states() { - let setup = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::SetupRequired, - home_route: HomeRoute::SetupRequired, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: None, - }; - let personal = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::Personal, - home_route: HomeRoute::Personal, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: None, - }; - let farmer = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::Farmer, - home_route: HomeRoute::FarmSetupOnboarding, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: None, - }; - let blocked = DesktopAppRuntimeSummary { - shell_projection: AppShellProjection::default(), - settings_account_projection: SettingsAccountProjection::default(), - startup_gate: AppStartupGate::Farmer, - home_route: HomeRoute::FarmSetupOnboarding, - farm_setup_projection: Default::default(), - today_projection: TodayAgendaProjection::default(), - products_projection: Default::default(), - logged_out_startup: LoggedOutStartupProjection::default(), - startup_issue: Some("runtime unavailable".to_owned()), - }; + let setup = summary_with_gate( + AppStartupGate::SetupRequired, + HomeRoute::SetupRequired, + None, + ); + let personal = summary_with_gate(AppStartupGate::Personal, HomeRoute::Personal, None); + let farmer = + summary_with_gate(AppStartupGate::Farmer, HomeRoute::FarmSetupOnboarding, None); + let blocked = summary_with_gate( + AppStartupGate::Farmer, + HomeRoute::FarmSetupOnboarding, + Some("runtime unavailable"), + ); assert_eq!(home_stage(&setup), HomeStage::Setup); assert_eq!(home_stage(&personal), HomeStage::PersonalHolding); diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -19,7 +19,8 @@ use radroots_app_sqlite::{ }; use radroots_app_state::{ AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, FarmSetupFlowStage, - HomeRoute, InMemoryAppStateRepository, ProductsScreenProjection, ProductsScreenQueryState, + FarmWorkspaceReadinessProjection, HomeRoute, InMemoryAppStateRepository, + ProductsScreenProjection, ProductsScreenQueryState, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; use thiserror::Error; @@ -64,6 +65,7 @@ impl DesktopAppRuntime { logged_out_startup: state.state_store.logged_out_startup_projection().clone(), home_route: state.state_store.home_route(), farm_setup_projection: state.state_store.farm_setup_projection().clone(), + farm_readiness_projection: state.state_store.farm_readiness_projection().clone(), today_projection: state.state_store.today_projection().clone(), products_projection: state.state_store.products_projection().clone(), startup_issue: state.startup_issue.clone(), @@ -378,6 +380,7 @@ pub struct DesktopAppRuntimeSummary { pub logged_out_startup: LoggedOutStartupProjection, pub home_route: HomeRoute, pub farm_setup_projection: FarmSetupProjection, + pub farm_readiness_projection: FarmWorkspaceReadinessProjection, pub today_projection: TodayAgendaProjection, pub products_projection: ProductsScreenProjection, pub startup_issue: Option<String>, @@ -394,6 +397,7 @@ pub enum DesktopAppRuntimeActivityContextError { #[derive(Clone, Debug, Default)] struct DesktopSelectedAccountContext { farm_setup_projection: FarmSetupProjection, + farm_rules_projection: FarmRulesProjection, today_projection: TodayAgendaProjection, products_list: ProductsListProjection, } @@ -952,6 +956,11 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::replace_farm_setup_projection( context.farm_setup_projection.clone(), )); + let farm_rules_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_farm_rules_projection( + context.farm_rules_projection.clone(), + )); let today_changed = self.state_store .apply_in_memory(AppStateCommand::replace_today_agenda( @@ -969,7 +978,12 @@ impl DesktopAppRuntimeState { }; let shell_changed = self.sync_truthful_farmer_section(); - farm_setup_changed || today_changed || products_changed || editor_changed || shell_changed + farm_setup_changed + || farm_rules_changed + || today_changed + || products_changed + || editor_changed + || shell_changed } fn selected_account_id(&self) -> Result<String, DesktopAppRuntimeFarmSetupError> { @@ -1081,30 +1095,7 @@ impl DesktopAppRuntimeState { } fn fallback_farm_profile(&self, farm_id: FarmId) -> FarmProfileRecord { - let saved_farm_name = self - .state_store - .farm_setup_projection() - .saved_farm - .as_ref() - .filter(|farm| farm.farm_id == farm_id) - .map(|farm| farm.display_name.clone()); - let drafted_farm_name = self - .state_store - .farm_setup_projection() - .draft - .farm_name - .trim() - .to_owned(); - let display_name = saved_farm_name - .or_else(|| (!drafted_farm_name.is_empty()).then_some(drafted_farm_name)) - .unwrap_or_default(); - - FarmProfileRecord { - farm_id, - display_name, - timezone: "UTC".to_owned(), - currency_code: "USD".to_owned(), - } + fallback_farm_profile_for_projection(farm_id, self.state_store.farm_setup_projection()) } fn load_products_list_for_query( @@ -1296,6 +1287,16 @@ fn load_selected_account_context( .saved_farm .as_ref() .map(|farm| farm.farm_id)); + let farm_rules_projection = match today_farm_id { + Some(farm_id) => { + let fallback_profile = + fallback_farm_profile_for_projection(farm_id, &farm_setup_projection); + sqlite_store.load_farm_rules(farm_id).map(|projection| { + prepare_loaded_farm_rules_projection(projection, &fallback_profile) + })? + } + None => FarmRulesProjection::default(), + }; let today_projection = match today_farm_id { Some(farm_id) => sqlite_store.load_today_agenda(Some(farm_id))?, None => TodayAgendaProjection::default(), @@ -1312,11 +1313,34 @@ fn load_selected_account_context( Ok(DesktopSelectedAccountContext { farm_setup_projection, + farm_rules_projection, today_projection, products_list, }) } +fn fallback_farm_profile_for_projection( + farm_id: FarmId, + farm_setup_projection: &FarmSetupProjection, +) -> FarmProfileRecord { + let saved_farm_name = farm_setup_projection + .saved_farm + .as_ref() + .filter(|farm| farm.farm_id == farm_id) + .map(|farm| farm.display_name.clone()); + let drafted_farm_name = farm_setup_projection.draft.farm_name.trim().to_owned(); + let display_name = saved_farm_name + .or_else(|| (!drafted_farm_name.is_empty()).then_some(drafted_farm_name)) + .unwrap_or_default(); + + FarmProfileRecord { + farm_id, + display_name, + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + } +} + fn prepare_loaded_farm_rules_projection( mut projection: FarmRulesProjection, fallback_profile: &FarmProfileRecord, @@ -1730,7 +1754,10 @@ mod tests { let summary = runtime.summary(); - assert_eq!(summary.today_projection, today_agenda); + assert_eq!(summary.today_projection.farm, today_agenda.farm); + assert_eq!(summary.today_projection.summary, today_agenda.summary); + assert_eq!(summary.today_projection.setup_checklist.len(), 6); + assert!(summary.today_projection.needs_setup()); assert_eq!(summary.home_route, HomeRoute::SetupRequired); assert_eq!( summary.shell_projection.active_surface, @@ -2733,7 +2760,7 @@ mod tests { summary.today_projection.farm, finished_projection.saved_farm.clone() ); - assert_eq!(summary.today_projection.setup_checklist.len(), 2); + assert_eq!(summary.today_projection.setup_checklist.len(), 6); assert_eq!( runtime .lock_state() diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -14,7 +14,7 @@ use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_models::{ AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, - FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmRulesProjection, + FarmOrderMethod, FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary, LoggedOutStartupPhase, OrderListRow, PickupLocationId, PickupLocationRecord, @@ -29,7 +29,9 @@ use radroots_app_remote_signer::{ radroots_app_remote_signer_preview, radroots_app_remote_signer_requested_permissions, }; use radroots_app_sqlite::derive_farm_rules_readiness; -use radroots_app_state::{FarmSetupFlowStage, HomeRoute}; +use radroots_app_state::{ + FarmSetupFlowStage, FarmWorkspaceStatus, HomeRoute, derive_product_publish_blockers, +}; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button, action_button_compact, action_button_primary, action_button_primary_disabled, @@ -1368,6 +1370,7 @@ impl HomeView { .when_some(self.product_editor_form.as_ref(), |this, form| { this.child(products_editor_surface( form, + runtime, cx.listener(|this, _, _, cx| { this.select_product_editor_status(ProductStatus::Draft, cx) }), @@ -5530,6 +5533,7 @@ fn products_stock_editor_validation_key( fn products_editor_surface( form: &ProductEditorFormState, + runtime: &DesktopAppRuntimeSummary, on_select_draft: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_select_live: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_select_paused: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -5585,7 +5589,7 @@ fn products_editor_surface( on_select_archived, cx, )) - .child(products_editor_publish_readiness_section(form, cx)) + .child(products_editor_publish_readiness_section(form, runtime, cx)) .when(form.save_failed, |this| { this.child(home_body_text(app_shared_text( AppTextKey::ProductsEditorSaveFailed, @@ -5713,9 +5717,13 @@ fn products_editor_status_section( fn products_editor_publish_readiness_section( form: &ProductEditorFormState, + runtime: &DesktopAppRuntimeSummary, cx: &App, ) -> impl IntoElement { - let blockers = form.publish_blockers(cx); + let blockers = form + .current_draft(cx) + .map(|draft| derive_product_publish_blockers(&draft, &runtime.farm_readiness_projection)) + .unwrap_or_default(); div() .w_full() @@ -5768,6 +5776,21 @@ fn products_editor_publish_blocker_key(blocker: ProductPublishBlocker) -> AppTex ProductPublishBlocker::AttachAvailability => { AppTextKey::ProductsEditorBlockerAttachAvailability } + ProductPublishBlocker::CompleteFarmProfile => { + AppTextKey::ProductsEditorBlockerCompleteFarmProfile + } + ProductPublishBlocker::AddPickupLocation => { + AppTextKey::ProductsEditorBlockerAddPickupLocation + } + ProductPublishBlocker::AddOperatingRules => { + AppTextKey::ProductsEditorBlockerAddOperatingRules + } + ProductPublishBlocker::AddFulfillmentWindow => { + AppTextKey::ProductsEditorBlockerAddFulfillmentWindow + } + ProductPublishBlocker::ResolveAvailabilityConflicts => { + AppTextKey::ProductsEditorBlockerResolveAvailabilityConflicts + } } } @@ -6954,14 +6977,16 @@ fn home_saved_farm(runtime: &DesktopAppRuntimeSummary) -> Option<&FarmSummary> { } fn farmer_home_farm_state(runtime: &DesktopAppRuntimeSummary) -> FarmerHomeFarmState { - let Some(saved_farm) = home_saved_farm(runtime) else { - return FarmerHomeFarmState::NoFarm; - }; - - if runtime.today_projection.needs_setup() || saved_farm.readiness == FarmReadiness::Incomplete { - FarmerHomeFarmState::IncompleteFarm - } else { - FarmerHomeFarmState::ConfiguredFarm + match runtime.farm_readiness_projection.status { + FarmWorkspaceStatus::NoFarm => FarmerHomeFarmState::NoFarm, + FarmWorkspaceStatus::SetupRequired => { + if home_saved_farm(runtime).is_some() { + FarmerHomeFarmState::IncompleteFarm + } else { + FarmerHomeFarmState::NoFarm + } + } + FarmWorkspaceStatus::Ready => FarmerHomeFarmState::ConfiguredFarm, } } @@ -7027,7 +7052,13 @@ fn home_status_presentation(runtime: &DesktopAppRuntimeSummary) -> HomeStatusPre fn home_setup_task_label_key(kind: TodaySetupTaskKind) -> AppTextKey { match kind { + TodaySetupTaskKind::CompleteFarmProfile => AppTextKey::HomeTodaySetupCompleteFarmProfile, + TodaySetupTaskKind::AddPickupLocation => AppTextKey::HomeTodaySetupAddPickupLocation, + TodaySetupTaskKind::AddOperatingRules => AppTextKey::HomeTodaySetupAddOperatingRules, TodaySetupTaskKind::AddFulfillmentWindow => AppTextKey::HomeTodaySetupAddFulfillmentWindow, + TodaySetupTaskKind::ResolveAvailabilityConflicts => { + AppTextKey::HomeTodaySetupResolveAvailabilityConflicts + } TodaySetupTaskKind::PublishProduct => AppTextKey::HomeTodaySetupPublishProduct, } } @@ -7064,8 +7095,9 @@ mod tests { RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, }; - use radroots_app_state::AppShellProjection; - use radroots_app_state::HomeRoute; + use radroots_app_state::{ + AppShellProjection, FarmWorkspaceReadinessProjection, FarmWorkspaceStatus, HomeRoute, + }; use radroots_identity::RadrootsIdentity; #[test] @@ -7464,12 +7496,32 @@ mod tests { today_projection: TodayAgendaProjection, farm_setup_projection: FarmSetupProjection, ) -> DesktopAppRuntimeSummary { + let farm_readiness_projection = match farm_setup_projection.saved_farm.as_ref() { + Some(saved_farm) + if saved_farm.readiness == FarmReadiness::Ready + && !today_projection.needs_setup() => + { + FarmWorkspaceReadinessProjection { + has_saved_farm: true, + status: FarmWorkspaceStatus::Ready, + ..FarmWorkspaceReadinessProjection::default() + } + } + Some(_) => FarmWorkspaceReadinessProjection { + has_saved_farm: true, + status: FarmWorkspaceStatus::SetupRequired, + ..FarmWorkspaceReadinessProjection::default() + }, + None => FarmWorkspaceReadinessProjection::default(), + }; + DesktopAppRuntimeSummary { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Farmer, logged_out_startup: LoggedOutStartupProjection::default(), home_route, + farm_readiness_projection, farm_setup_projection, today_projection, products_projection: Default::default(), diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -39,7 +39,11 @@ define_app_text_keys! { HomeTodayWindowStartsLabel => "home.today.window.starts", HomeTodayWindowEndsLabel => "home.today.window.ends", HomeTodayStockCountLabel => "home.today.stock_count.label", + HomeTodaySetupCompleteFarmProfile => "home.today.setup.complete_farm_profile", + HomeTodaySetupAddPickupLocation => "home.today.setup.add_pickup_location", + HomeTodaySetupAddOperatingRules => "home.today.setup.add_operating_rules", HomeTodaySetupAddFulfillmentWindow => "home.today.setup.add_fulfillment_window", + HomeTodaySetupResolveAvailabilityConflicts => "home.today.setup.resolve_availability_conflicts", HomeTodaySetupPublishProduct => "home.today.setup.publish_product", HomeSetupTitle => "home.setup.title", HomeSetupTagline => "home.setup.tagline", @@ -133,6 +137,11 @@ define_app_text_keys! { ProductsEditorBlockerChooseUnit => "products.editor.blocker.choose_unit", ProductsEditorBlockerSetPrice => "products.editor.blocker.set_price", ProductsEditorBlockerAttachAvailability => "products.editor.blocker.attach_availability", + ProductsEditorBlockerCompleteFarmProfile => "products.editor.blocker.complete_farm_profile", + ProductsEditorBlockerAddPickupLocation => "products.editor.blocker.add_pickup_location", + ProductsEditorBlockerAddOperatingRules => "products.editor.blocker.add_operating_rules", + ProductsEditorBlockerAddFulfillmentWindow => "products.editor.blocker.add_fulfillment_window", + ProductsEditorBlockerResolveAvailabilityConflicts => "products.editor.blocker.resolve_availability_conflicts", ProductsUntitledDraft => "products.untitled_draft", ProductsStockEditorTitle => "products.stock_editor.title", ProductsStockEditorFieldLabel => "products.stock_editor.field.label", diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -1036,6 +1036,11 @@ pub enum ProductPublishBlocker { ChooseUnit, SetPrice, AttachAvailability, + CompleteFarmProfile, + AddPickupLocation, + AddOperatingRules, + AddFulfillmentWindow, + ResolveAvailabilityConflicts, } impl ProductPublishBlocker { @@ -1045,6 +1050,11 @@ impl ProductPublishBlocker { Self::ChooseUnit => "choose_unit", Self::SetPrice => "set_price", Self::AttachAvailability => "attach_availability", + Self::CompleteFarmProfile => "complete_farm_profile", + Self::AddPickupLocation => "add_pickup_location", + Self::AddOperatingRules => "add_operating_rules", + Self::AddFulfillmentWindow => "add_fulfillment_window", + Self::ResolveAvailabilityConflicts => "resolve_availability_conflicts", } } } @@ -1399,7 +1409,11 @@ pub struct OrderListRow { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TodaySetupTaskKind { + CompleteFarmProfile, + AddPickupLocation, + AddOperatingRules, AddFulfillmentWindow, + ResolveAvailabilityConflicts, PublishProduct, } diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -1,11 +1,12 @@ #![forbid(unsafe_code)] use radroots_app_models::{ - ActiveSurface, AppIdentityProjection, AppStartupGate, FarmSetupProjection, FarmSetupReadiness, - LoggedOutStartupPhase, LoggedOutStartupProjection, ProductEditorDraft, ProductId, - ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, + ActiveSurface, AppIdentityProjection, AppStartupGate, FarmReadiness, FarmReadinessBlocker, + FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection, FarmSetupReadiness, + FarmTimingConflict, LoggedOutStartupPhase, LoggedOutStartupProjection, ProductEditorDraft, + ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, - ShellSection, TodayAgendaProjection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use thiserror::Error; @@ -119,16 +120,24 @@ pub struct ProductEditorSession { } impl ProductEditorSession { - fn new_draft() -> Self { - Self::from_selection(None, ProductEditorDraft::default()) + fn new_draft(farm_readiness: &FarmWorkspaceReadinessProjection) -> Self { + Self::from_selection(None, ProductEditorDraft::default(), farm_readiness) } - fn existing(product_id: ProductId, draft: ProductEditorDraft) -> Self { - Self::from_selection(Some(product_id), draft) + fn existing( + product_id: ProductId, + draft: ProductEditorDraft, + farm_readiness: &FarmWorkspaceReadinessProjection, + ) -> Self { + Self::from_selection(Some(product_id), draft, farm_readiness) } - fn from_selection(selected_product_id: Option<ProductId>, draft: ProductEditorDraft) -> Self { - let publish_blockers = draft.publish_blockers(); + fn from_selection( + selected_product_id: Option<ProductId>, + draft: ProductEditorDraft, + farm_readiness: &FarmWorkspaceReadinessProjection, + ) -> Self { + let publish_blockers = derive_product_publish_blockers(&draft, farm_readiness); Self { selected_product_id, @@ -137,8 +146,12 @@ impl ProductEditorSession { } } - fn replace_draft(&mut self, draft: ProductEditorDraft) { - self.publish_blockers = draft.publish_blockers(); + fn replace_draft( + &mut self, + draft: ProductEditorDraft, + farm_readiness: &FarmWorkspaceReadinessProjection, + ) { + self.publish_blockers = derive_product_publish_blockers(&draft, farm_readiness); self.draft = draft; } } @@ -156,17 +169,30 @@ impl Default for ProductEditorState { } impl ProductEditorState { - fn open_new_draft(&mut self) { - *self = Self::Open(ProductEditorSession::new_draft()); + fn open_new_draft(&mut self, farm_readiness: &FarmWorkspaceReadinessProjection) { + *self = Self::Open(ProductEditorSession::new_draft(farm_readiness)); } - fn open_existing(&mut self, product_id: ProductId, draft: ProductEditorDraft) { - *self = Self::Open(ProductEditorSession::existing(product_id, draft)); + fn open_existing( + &mut self, + product_id: ProductId, + draft: ProductEditorDraft, + farm_readiness: &FarmWorkspaceReadinessProjection, + ) { + *self = Self::Open(ProductEditorSession::existing( + product_id, + draft, + farm_readiness, + )); } - fn replace_draft(&mut self, draft: ProductEditorDraft) { + fn replace_draft( + &mut self, + draft: ProductEditorDraft, + farm_readiness: &FarmWorkspaceReadinessProjection, + ) { if let Self::Open(session) = self { - session.replace_draft(draft); + session.replace_draft(draft, farm_readiness); } } @@ -189,6 +215,41 @@ pub enum FarmSetupFlowStage { Editing, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum FarmWorkspaceStatus { + #[default] + NoFarm, + SetupRequired, + Ready, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct FarmWorkspaceReadinessProjection { + pub has_saved_farm: bool, + pub status: FarmWorkspaceStatus, + pub setup_blockers: Vec<FarmSetupBlocker>, + pub rules_blockers: Vec<FarmReadinessBlocker>, + pub timing_conflicts: Vec<FarmTimingConflict>, +} + +impl FarmWorkspaceReadinessProjection { + pub const fn needs_setup(&self) -> bool { + matches!(self.status, FarmWorkspaceStatus::SetupRequired) + } + + pub fn coarse_readiness(&self) -> Option<FarmReadiness> { + self.has_saved_farm.then_some(if self.needs_setup() { + FarmReadiness::Incomplete + } else { + FarmReadiness::Ready + }) + } + + fn has_rules_blocker(&self, blocker: FarmReadinessBlocker) -> bool { + self.rules_blockers.contains(&blocker) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum HomeRoute { Blocked, @@ -286,6 +347,8 @@ pub struct AppProjection { pub today: TodayAgendaProjection, pub products: ProductsScreenProjection, pub farm_setup: FarmSetupProjection, + pub farm_rules: FarmRulesProjection, + pub farm_readiness: FarmWorkspaceReadinessProjection, pub farm_setup_flow_stage: FarmSetupFlowStage, } @@ -312,6 +375,8 @@ impl AppProjection { today, products: ProductsScreenProjection::default(), farm_setup, + farm_rules: FarmRulesProjection::default(), + farm_readiness: FarmWorkspaceReadinessProjection::default(), farm_setup_flow_stage: FarmSetupFlowStage::default(), }; sync_projection(&mut projection); @@ -358,6 +423,7 @@ pub enum AppStateCommand { ResetLoggedOutStartup, ReplaceIdentityProjection(AppIdentityProjection), ReplaceFarmSetupProjection(FarmSetupProjection), + ReplaceFarmRulesProjection(FarmRulesProjection), SelectFarmSetupFlowStage(FarmSetupFlowStage), SetSettingsPreference { preference: SettingsPreference, @@ -414,6 +480,10 @@ impl AppStateCommand { Self::ReplaceFarmSetupProjection(projection) } + pub fn replace_farm_rules_projection(projection: FarmRulesProjection) -> Self { + Self::ReplaceFarmRulesProjection(projection) + } + pub const fn select_farm_setup_flow_stage(stage: FarmSetupFlowStage) -> Self { Self::SelectFarmSetupFlowStage(stage) } @@ -571,6 +641,14 @@ impl<R: AppStateRepository> AppStateStore<R> { &self.projection.farm_setup } + pub fn farm_rules_projection(&self) -> &FarmRulesProjection { + &self.projection.farm_rules + } + + pub fn farm_readiness_projection(&self) -> &FarmWorkspaceReadinessProjection { + &self.projection.farm_readiness + } + pub fn logged_out_startup_projection(&self) -> &LoggedOutStartupProjection { &self.projection.logged_out_startup } @@ -742,6 +820,9 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateCommand::ReplaceFarmSetupProjection(farm_setup_projection) => { projection.farm_setup = farm_setup_projection; } + AppStateCommand::ReplaceFarmRulesProjection(farm_rules_projection) => { + projection.farm_rules = farm_rules_projection; + } AppStateCommand::SelectFarmSetupFlowStage(flow_stage) => { projection.farm_setup_flow_stage = flow_stage; } @@ -771,13 +852,22 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap projection.products.list = products_projection; } AppStateCommand::OpenNewProductEditor => { - projection.products.editor.open_new_draft(); + projection + .products + .editor + .open_new_draft(&projection.farm_readiness); } AppStateCommand::OpenExistingProductEditor { product_id, draft } => { - projection.products.editor.open_existing(product_id, draft); + projection + .products + .editor + .open_existing(product_id, draft, &projection.farm_readiness); } AppStateCommand::ReplaceProductEditorDraft(draft) => { - projection.products.editor.replace_draft(draft); + projection + .products + .editor + .replace_draft(draft, &projection.farm_readiness); } AppStateCommand::CloseProductEditor => { projection.products.editor.close(); @@ -791,6 +881,8 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap } else if projection.shell != before.shell { AppStateMutation::ShellChanged } else if projection.farm_setup != before.farm_setup + || projection.farm_rules != before.farm_rules + || projection.farm_readiness != before.farm_readiness || projection.farm_setup_flow_stage != before.farm_setup_flow_stage { AppStateMutation::FarmSetupChanged @@ -806,6 +898,19 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap fn sync_projection(projection: &mut AppProjection) { sync_shell_to_identity(&mut projection.shell, &projection.identity); sync_farm_setup_to_today(&mut projection.farm_setup, &projection.today); + projection.farm_readiness = + derive_farm_workspace_readiness(&projection.farm_setup, &projection.farm_rules); + sync_coarse_farm_readiness( + &mut projection.farm_setup, + &mut projection.today, + &projection.farm_readiness, + ); + projection.today.setup_checklist = + derive_today_setup_checklist(&projection.farm_readiness, &projection.products.list); + sync_product_editor_publish_blockers( + &mut projection.products.editor, + &projection.farm_readiness, + ); projection.startup_gate = projection.identity.startup_gate(); sync_logged_out_startup(&mut projection.logged_out_startup, projection.startup_gate); sync_farm_setup_flow_stage( @@ -859,6 +964,170 @@ fn sync_logged_out_startup( } } +pub fn derive_farm_workspace_readiness( + farm_setup: &FarmSetupProjection, + farm_rules: &FarmRulesProjection, +) -> FarmWorkspaceReadinessProjection { + if !farm_setup.has_saved_farm() { + return FarmWorkspaceReadinessProjection { + has_saved_farm: false, + status: if farm_setup.readiness == FarmSetupReadiness::NotStarted { + FarmWorkspaceStatus::NoFarm + } else { + FarmWorkspaceStatus::SetupRequired + }, + setup_blockers: farm_setup.blockers.clone(), + rules_blockers: Vec::new(), + timing_conflicts: Vec::new(), + }; + } + + let status = if farm_rules.is_ready() { + FarmWorkspaceStatus::Ready + } else { + FarmWorkspaceStatus::SetupRequired + }; + + FarmWorkspaceReadinessProjection { + has_saved_farm: true, + status, + setup_blockers: Vec::new(), + rules_blockers: farm_rules.readiness.blockers.clone(), + timing_conflicts: farm_rules.readiness.timing_conflicts.clone(), + } +} + +pub fn derive_today_setup_checklist( + farm_readiness: &FarmWorkspaceReadinessProjection, + products: &ProductsListProjection, +) -> Vec<TodaySetupTask> { + if !farm_readiness.has_saved_farm { + return Vec::new(); + } + + vec![ + TodaySetupTask { + kind: TodaySetupTaskKind::CompleteFarmProfile, + is_complete: !farm_readiness + .has_rules_blocker(FarmReadinessBlocker::MissingProfileBasics), + }, + TodaySetupTask { + kind: TodaySetupTaskKind::AddPickupLocation, + is_complete: !farm_readiness + .has_rules_blocker(FarmReadinessBlocker::MissingPickupLocation), + }, + TodaySetupTask { + kind: TodaySetupTaskKind::AddOperatingRules, + is_complete: !farm_readiness + .has_rules_blocker(FarmReadinessBlocker::MissingOperatingRules), + }, + TodaySetupTask { + kind: TodaySetupTaskKind::AddFulfillmentWindow, + is_complete: !farm_readiness + .has_rules_blocker(FarmReadinessBlocker::MissingFulfillmentWindow), + }, + TodaySetupTask { + kind: TodaySetupTaskKind::ResolveAvailabilityConflicts, + is_complete: farm_readiness.timing_conflicts.is_empty(), + }, + TodaySetupTask { + kind: TodaySetupTaskKind::PublishProduct, + is_complete: products.summary.live_products > 0, + }, + ] +} + +pub fn derive_product_publish_blockers( + draft: &ProductEditorDraft, + farm_readiness: &FarmWorkspaceReadinessProjection, +) -> Vec<ProductPublishBlocker> { + let mut blockers = draft.publish_blockers(); + + if farm_readiness.has_saved_farm { + replace_availability_blocker(&mut blockers, farm_readiness); + + if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingProfileBasics) { + push_unique_product_blocker(&mut blockers, ProductPublishBlocker::CompleteFarmProfile); + } + + if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingPickupLocation) { + push_unique_product_blocker(&mut blockers, ProductPublishBlocker::AddPickupLocation); + } + + if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingOperatingRules) { + push_unique_product_blocker(&mut blockers, ProductPublishBlocker::AddOperatingRules); + } + + if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingFulfillmentWindow) { + push_unique_product_blocker(&mut blockers, ProductPublishBlocker::AddFulfillmentWindow); + } + + if !farm_readiness.timing_conflicts.is_empty() { + push_unique_product_blocker( + &mut blockers, + ProductPublishBlocker::ResolveAvailabilityConflicts, + ); + } + } + + blockers +} + +fn sync_coarse_farm_readiness( + farm_setup: &mut FarmSetupProjection, + today: &mut TodayAgendaProjection, + farm_readiness: &FarmWorkspaceReadinessProjection, +) { + let Some(coarse_readiness) = farm_readiness.coarse_readiness() else { + return; + }; + + if let Some(saved_farm) = farm_setup.saved_farm.as_mut() { + saved_farm.readiness = coarse_readiness; + } + + if let Some(saved_farm) = today.farm.as_mut() { + saved_farm.readiness = coarse_readiness; + } +} + +fn sync_product_editor_publish_blockers( + editor: &mut ProductEditorState, + farm_readiness: &FarmWorkspaceReadinessProjection, +) { + if let ProductEditorState::Open(session) = editor { + session.publish_blockers = derive_product_publish_blockers(&session.draft, farm_readiness); + } +} + +fn replace_availability_blocker( + blockers: &mut [ProductPublishBlocker], + farm_readiness: &FarmWorkspaceReadinessProjection, +) { + for blocker in blockers.iter_mut() { + if *blocker != ProductPublishBlocker::AttachAvailability { + continue; + } + + *blocker = if !farm_readiness.timing_conflicts.is_empty() { + ProductPublishBlocker::ResolveAvailabilityConflicts + } else if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingFulfillmentWindow) { + ProductPublishBlocker::AddFulfillmentWindow + } else { + ProductPublishBlocker::AttachAvailability + }; + } +} + +fn push_unique_product_blocker( + blockers: &mut Vec<ProductPublishBlocker>, + blocker: ProductPublishBlocker, +) { + if !blockers.contains(&blocker) { + blockers.push(blocker); + } +} + #[cfg(test)] mod tests { use super::{ @@ -1463,7 +1732,13 @@ mod tests { fn replace_today_agenda_updates_in_memory_state_without_touching_repository() { let mut store = AppStateStore::load(FailingRepository).expect("failing repository should still load"); + let farm_id = FarmId::new(); let today = TodayAgendaProjection { + farm: Some(radroots_app_models::FarmSummary { + farm_id, + display_name: "North field farm".to_owned(), + readiness: FarmReadiness::Incomplete, + }), setup_checklist: vec![TodaySetupTask { kind: TodaySetupTaskKind::AddFulfillmentWindow, is_complete: false, @@ -1474,7 +1749,9 @@ mod tests { let changed = store.apply(AppStateCommand::replace_today_agenda(today.clone())); assert_eq!(changed, Ok(true)); - assert_eq!(store.projection().today, today); + assert_eq!(store.projection().today.farm, today.farm); + assert_eq!(store.projection().today.setup_checklist.len(), 6); + assert!(store.projection().today.needs_setup()); } #[test] diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -18,7 +18,11 @@ "home.today.window.starts": "Starts", "home.today.window.ends": "Ends", "home.today.stock_count.label": "Stock", + "home.today.setup.complete_farm_profile": "Complete the farm profile", + "home.today.setup.add_pickup_location": "Add a pickup location", + "home.today.setup.add_operating_rules": "Add operating rules", "home.today.setup.add_fulfillment_window": "Add a fulfillment window", + "home.today.setup.resolve_availability_conflicts": "Resolve availability conflicts", "home.today.setup.publish_product": "Publish a product", "home.setup.title": "Radroots", "home.setup.tagline": "Grow from the root", @@ -112,6 +116,11 @@ "products.editor.blocker.choose_unit": "Choose a unit.", "products.editor.blocker.set_price": "Set a price.", "products.editor.blocker.attach_availability": "Attach an availability window.", + "products.editor.blocker.complete_farm_profile": "Complete the farm profile in Settings before publishing.", + "products.editor.blocker.add_pickup_location": "Add a pickup location in Settings before publishing.", + "products.editor.blocker.add_operating_rules": "Add operating rules in Settings before publishing.", + "products.editor.blocker.add_fulfillment_window": "Add a fulfillment window in Settings before publishing.", + "products.editor.blocker.resolve_availability_conflicts": "Resolve farm availability conflicts in Settings before publishing.", "products.untitled_draft": "Untitled draft", "products.stock_editor.title": "Update stock", "products.stock_editor.field.label": "Stock",