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:
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(°raded), 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",