commit ebb2b3a1eb185b1b42c77587f192ec5307c5e358
parent 7d94a19fb82ef14691e622490916b04587267e83
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Apr 2026 06:41:32 +0000
app: add typed farm setup state
Diffstat:
2 files changed, 479 insertions(+), 16 deletions(-)
diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs
@@ -1,7 +1,7 @@
#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
-use std::{error::Error, fmt, str::FromStr};
+use std::{collections::BTreeSet, error::Error, fmt, str::FromStr};
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
@@ -527,6 +527,196 @@ pub struct FarmSummary {
pub readiness: FarmReadiness,
}
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FarmSetupReadiness {
+ #[default]
+ NotStarted,
+ InProgress,
+ Ready,
+}
+
+impl FarmSetupReadiness {
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::NotStarted => "not_started",
+ Self::InProgress => "in_progress",
+ Self::Ready => "ready",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FarmOrderMethod {
+ Pickup,
+ Delivery,
+ Shipping,
+}
+
+impl FarmOrderMethod {
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::Pickup => "pickup",
+ Self::Delivery => "delivery",
+ Self::Shipping => "shipping",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FarmSetupSection {
+ Farm,
+ Location,
+ OrderMethods,
+}
+
+impl FarmSetupSection {
+ pub const fn ordered() -> [Self; 3] {
+ [Self::Farm, Self::Location, Self::OrderMethods]
+ }
+
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::Farm => "farm",
+ Self::Location => "location",
+ Self::OrderMethods => "order_methods",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FarmSetupBlocker {
+ AddFarmName,
+ AddLocationOrServiceArea,
+ ChooseOrderMethod,
+}
+
+impl FarmSetupBlocker {
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::AddFarmName => "add_farm_name",
+ Self::AddLocationOrServiceArea => "add_location_or_service_area",
+ Self::ChooseOrderMethod => "choose_order_method",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+pub struct FarmSetupDraft {
+ pub farm_name: String,
+ pub location_or_service_area: String,
+ pub order_methods: BTreeSet<FarmOrderMethod>,
+}
+
+impl FarmSetupDraft {
+ pub fn new(
+ farm_name: impl Into<String>,
+ location_or_service_area: impl Into<String>,
+ order_methods: impl IntoIterator<Item = FarmOrderMethod>,
+ ) -> Self {
+ Self {
+ farm_name: farm_name.into(),
+ location_or_service_area: location_or_service_area.into(),
+ order_methods: order_methods.into_iter().collect(),
+ }
+ }
+
+ pub fn blockers(&self) -> Vec<FarmSetupBlocker> {
+ let mut blockers = Vec::new();
+
+ if self.farm_name.trim().is_empty() {
+ blockers.push(FarmSetupBlocker::AddFarmName);
+ }
+
+ if self.location_or_service_area.trim().is_empty() {
+ blockers.push(FarmSetupBlocker::AddLocationOrServiceArea);
+ }
+
+ if self.order_methods.is_empty() {
+ blockers.push(FarmSetupBlocker::ChooseOrderMethod);
+ }
+
+ blockers
+ }
+
+ pub fn readiness(&self) -> FarmSetupReadiness {
+ let blockers = self.blockers();
+ if blockers.is_empty() {
+ FarmSetupReadiness::Ready
+ } else if self.is_empty() {
+ FarmSetupReadiness::NotStarted
+ } else {
+ FarmSetupReadiness::InProgress
+ }
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.farm_name.trim().is_empty()
+ && self.location_or_service_area.trim().is_empty()
+ && self.order_methods.is_empty()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct FarmSetupProjection {
+ pub draft: FarmSetupDraft,
+ pub saved_farm: Option<FarmSummary>,
+ pub readiness: FarmSetupReadiness,
+ pub blockers: Vec<FarmSetupBlocker>,
+}
+
+impl Default for FarmSetupProjection {
+ fn default() -> Self {
+ Self::not_started()
+ }
+}
+
+impl FarmSetupProjection {
+ pub fn new(draft: FarmSetupDraft, saved_farm: Option<FarmSummary>) -> Self {
+ match saved_farm {
+ Some(saved_farm) => Self {
+ draft,
+ saved_farm: Some(saved_farm),
+ readiness: FarmSetupReadiness::Ready,
+ blockers: Vec::new(),
+ },
+ None => Self::from_draft(draft),
+ }
+ }
+
+ pub fn not_started() -> Self {
+ Self::from_draft(FarmSetupDraft::default())
+ }
+
+ pub fn from_draft(draft: FarmSetupDraft) -> Self {
+ let readiness = draft.readiness();
+ let blockers = draft.blockers();
+
+ Self {
+ draft,
+ saved_farm: None,
+ readiness,
+ blockers,
+ }
+ }
+
+ pub fn from_saved_farm(saved_farm: FarmSummary) -> Self {
+ Self {
+ draft: FarmSetupDraft::default(),
+ saved_farm: Some(saved_farm),
+ readiness: FarmSetupReadiness::Ready,
+ blockers: Vec::new(),
+ }
+ }
+
+ pub const fn has_saved_farm(&self) -> bool {
+ self.saved_farm.is_some()
+ }
+}
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct FulfillmentWindowSummary {
pub fulfillment_window_id: FulfillmentWindowId,
@@ -656,10 +846,12 @@ mod tests {
use super::{
AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface,
ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind,
- AppIdentityProjection, AppStartupGate, FarmId, FarmerActivationProjection, FarmerSection,
- IdentityBlockedReason, IdentityReadiness, OrderListRow, ProductListRow,
- SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection,
- ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary,
+ AppIdentityProjection, AppStartupGate, FarmId, FarmOrderMethod, FarmSetupBlocker,
+ FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness, FarmSetupSection,
+ FarmerActivationProjection, FarmerSection, IdentityBlockedReason, IdentityReadiness,
+ OrderListRow, ProductListRow, SelectedAccountProjection, SelectedSurfaceProjection,
+ SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask,
+ TodaySetupTaskKind, TodaySummary,
};
use std::{collections::BTreeSet, str::FromStr};
use uuid::Uuid;
@@ -966,6 +1158,80 @@ mod tests {
}
#[test]
+ fn farm_setup_section_order_is_frozen() {
+ assert_eq!(
+ FarmSetupSection::ordered(),
+ [
+ FarmSetupSection::Farm,
+ FarmSetupSection::Location,
+ FarmSetupSection::OrderMethods,
+ ]
+ );
+ }
+
+ #[test]
+ fn empty_farm_setup_draft_is_not_started_with_all_blockers() {
+ let draft = FarmSetupDraft::default();
+
+ assert!(draft.is_empty());
+ assert_eq!(draft.readiness(), FarmSetupReadiness::NotStarted);
+ assert_eq!(
+ draft.blockers(),
+ vec![
+ FarmSetupBlocker::AddFarmName,
+ FarmSetupBlocker::AddLocationOrServiceArea,
+ FarmSetupBlocker::ChooseOrderMethod,
+ ]
+ );
+ }
+
+ #[test]
+ fn partial_farm_setup_draft_is_in_progress() {
+ let draft = FarmSetupDraft::new("North field farm", "", [FarmOrderMethod::Pickup]);
+
+ assert_eq!(draft.readiness(), FarmSetupReadiness::InProgress);
+ assert_eq!(
+ draft.blockers(),
+ vec![FarmSetupBlocker::AddLocationOrServiceArea]
+ );
+ }
+
+ #[test]
+ fn complete_farm_setup_draft_is_ready_and_deduplicates_order_methods() {
+ let draft = FarmSetupDraft::new(
+ "North field farm",
+ "Asheville, NC",
+ [
+ FarmOrderMethod::Shipping,
+ FarmOrderMethod::Pickup,
+ FarmOrderMethod::Shipping,
+ ],
+ );
+
+ assert_eq!(draft.readiness(), FarmSetupReadiness::Ready);
+ assert_eq!(draft.blockers(), Vec::<FarmSetupBlocker>::new());
+ assert_eq!(
+ draft.order_methods,
+ BTreeSet::from([FarmOrderMethod::Pickup, FarmOrderMethod::Shipping])
+ );
+ }
+
+ #[test]
+ fn saved_farm_projection_is_always_ready() {
+ let saved_farm = super::FarmSummary {
+ farm_id: FarmId::new(),
+ display_name: "North field farm".to_owned(),
+ readiness: super::FarmReadiness::Ready,
+ };
+ let projection = FarmSetupProjection::from_saved_farm(saved_farm.clone());
+
+ assert_eq!(projection.saved_farm, Some(saved_farm));
+ assert_eq!(projection.readiness, FarmSetupReadiness::Ready);
+ assert!(projection.blockers.is_empty());
+ assert!(projection.has_saved_farm());
+ }
+
+ #[test]
fn settings_preference_storage_keys_are_stable() {
assert_eq!(
SettingsPreference::AllowRelayConnections.storage_key(),
diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs
@@ -1,9 +1,9 @@
#![forbid(unsafe_code)]
use radroots_app_models::{
- ActiveSurface, AppIdentityProjection, AppStartupGate, SelectedSurfaceProjection,
- SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection,
- TodayAgendaProjection,
+ ActiveSurface, AppIdentityProjection, AppStartupGate, FarmSetupProjection, FarmSetupReadiness,
+ SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection,
+ ShellSection, TodayAgendaProjection,
};
use thiserror::Error;
@@ -66,6 +66,23 @@ impl SettingsShellProjection {
}
}
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum FarmSetupFlowStage {
+ #[default]
+ Onboarding,
+ Editing,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum HomeRoute {
+ Blocked,
+ SetupRequired,
+ Personal,
+ FarmSetupOnboarding,
+ FarmSetupForm,
+ Today,
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppShellProjection {
pub active_surface: ActiveSurface,
@@ -150,22 +167,54 @@ pub struct AppProjection {
pub identity: AppIdentityProjection,
pub startup_gate: AppStartupGate,
pub today: TodayAgendaProjection,
+ pub farm_setup: FarmSetupProjection,
+ pub farm_setup_flow_stage: FarmSetupFlowStage,
}
impl AppProjection {
pub fn new(
- mut shell: AppShellProjection,
+ shell: AppShellProjection,
identity: AppIdentityProjection,
today: TodayAgendaProjection,
) -> Self {
- sync_shell_to_identity(&mut shell, &identity);
- let startup_gate = identity.startup_gate();
+ Self::with_farm_setup(shell, identity, today, FarmSetupProjection::default())
+ }
- Self {
+ pub fn with_farm_setup(
+ shell: AppShellProjection,
+ identity: AppIdentityProjection,
+ today: TodayAgendaProjection,
+ farm_setup: FarmSetupProjection,
+ ) -> Self {
+ let mut projection = Self {
shell,
identity,
- startup_gate,
+ startup_gate: AppStartupGate::default(),
today,
+ farm_setup,
+ farm_setup_flow_stage: FarmSetupFlowStage::default(),
+ };
+ sync_projection(&mut projection);
+
+ projection
+ }
+
+ pub fn home_route(&self) -> HomeRoute {
+ match self.startup_gate {
+ AppStartupGate::Blocked => HomeRoute::Blocked,
+ AppStartupGate::SetupRequired => HomeRoute::SetupRequired,
+ AppStartupGate::Personal => HomeRoute::Personal,
+ AppStartupGate::Farmer => match self.farm_setup.readiness {
+ FarmSetupReadiness::Ready => HomeRoute::Today,
+ FarmSetupReadiness::NotStarted
+ if self.farm_setup_flow_stage == FarmSetupFlowStage::Onboarding =>
+ {
+ HomeRoute::FarmSetupOnboarding
+ }
+ FarmSetupReadiness::NotStarted | FarmSetupReadiness::InProgress => {
+ HomeRoute::FarmSetupForm
+ }
+ },
}
}
}
@@ -186,6 +235,8 @@ pub enum AppStateCommand {
SelectSection(ShellSection),
SelectSettingsSection(SettingsSection),
ReplaceIdentityProjection(AppIdentityProjection),
+ ReplaceFarmSetupProjection(FarmSetupProjection),
+ SelectFarmSetupFlowStage(FarmSetupFlowStage),
SetSettingsPreference {
preference: SettingsPreference,
enabled: bool,
@@ -206,6 +257,14 @@ impl AppStateCommand {
Self::ReplaceIdentityProjection(projection)
}
+ pub fn replace_farm_setup_projection(projection: FarmSetupProjection) -> Self {
+ Self::ReplaceFarmSetupProjection(projection)
+ }
+
+ pub const fn select_farm_setup_flow_stage(stage: FarmSetupFlowStage) -> Self {
+ Self::SelectFarmSetupFlowStage(stage)
+ }
+
pub fn replace_today_agenda(projection: TodayAgendaProjection) -> Self {
Self::ReplaceTodayAgenda(projection)
}
@@ -323,6 +382,14 @@ impl<R: AppStateRepository> AppStateStore<R> {
&self.projection.identity
}
+ pub fn farm_setup_projection(&self) -> &FarmSetupProjection {
+ &self.projection.farm_setup
+ }
+
+ pub fn home_route(&self) -> HomeRoute {
+ self.projection.home_route()
+ }
+
pub fn settings_account_projection(&self) -> SettingsAccountProjection {
self.projection.identity.settings_account()
}
@@ -347,6 +414,11 @@ impl<R: AppStateRepository> AppStateStore<R> {
Ok(true)
}
+ AppStateMutation::FarmSetupChanged => {
+ self.projection = next_projection;
+
+ Ok(true)
+ }
AppStateMutation::TodayChanged => {
self.projection = next_projection;
@@ -379,6 +451,11 @@ impl AppStateStore<InMemoryAppStateRepository> {
true
}
+ AppStateMutation::FarmSetupChanged => {
+ self.projection = next_projection;
+
+ true
+ }
AppStateMutation::TodayChanged => {
self.projection = next_projection;
@@ -392,6 +469,7 @@ impl AppStateStore<InMemoryAppStateRepository> {
enum AppStateMutation {
NoChange,
ShellChanged,
+ FarmSetupChanged,
TodayChanged,
}
@@ -420,6 +498,12 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap
AppStateCommand::ReplaceIdentityProjection(identity_projection) => {
projection.identity = identity_projection;
}
+ AppStateCommand::ReplaceFarmSetupProjection(farm_setup_projection) => {
+ projection.farm_setup = farm_setup_projection;
+ }
+ AppStateCommand::SelectFarmSetupFlowStage(flow_stage) => {
+ projection.farm_setup_flow_stage = flow_stage;
+ }
AppStateCommand::SetSettingsPreference {
preference,
enabled,
@@ -441,6 +525,10 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap
AppStateMutation::NoChange
} else if projection.shell != before.shell {
AppStateMutation::ShellChanged
+ } else if projection.farm_setup != before.farm_setup
+ || projection.farm_setup_flow_stage != before.farm_setup_flow_stage
+ {
+ AppStateMutation::FarmSetupChanged
} else {
AppStateMutation::TodayChanged
}
@@ -448,7 +536,13 @@ 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.startup_gate = projection.identity.startup_gate();
+ sync_farm_setup_flow_stage(
+ &mut projection.farm_setup_flow_stage,
+ projection.startup_gate,
+ projection.farm_setup.readiness,
+ );
}
fn sync_shell_to_identity(shell: &mut AppShellProjection, identity: &AppIdentityProjection) {
@@ -468,16 +562,35 @@ fn sync_shell_to_identity(shell: &mut AppShellProjection, identity: &AppIdentity
}
}
+fn sync_farm_setup_to_today(farm_setup: &mut FarmSetupProjection, today: &TodayAgendaProjection) {
+ if let Some(saved_farm) = today.farm.clone()
+ && !farm_setup.has_saved_farm()
+ {
+ *farm_setup = FarmSetupProjection::from_saved_farm(saved_farm);
+ }
+}
+
+fn sync_farm_setup_flow_stage(
+ flow_stage: &mut FarmSetupFlowStage,
+ startup_gate: AppStartupGate,
+ readiness: FarmSetupReadiness,
+) {
+ if startup_gate != AppStartupGate::Farmer || readiness == FarmSetupReadiness::Ready {
+ *flow_stage = FarmSetupFlowStage::Onboarding;
+ }
+}
+
#[cfg(test)]
mod tests {
use super::{
AppProjection, AppShellProjection, AppStateCommand, AppStateRepository,
- AppStateRepositoryError, AppStateStore, AppStateStoreError, InMemoryAppStateRepository,
- SettingsPreference,
+ AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute,
+ InMemoryAppStateRepository, SettingsPreference,
};
use radroots_app_models::{
AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate,
- FarmId, FarmerActivationProjection, FarmerSection, SelectedAccountProjection,
+ FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection,
+ FarmerActivationProjection, FarmerSection, SelectedAccountProjection,
SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection,
TodaySetupTask, TodaySetupTaskKind,
};
@@ -530,6 +643,12 @@ mod tests {
assert!(projection.shell.settings.general.use_nip05);
assert!(!projection.shell.settings.general.launch_at_login);
assert_eq!(projection.today, TodayAgendaProjection::default());
+ assert_eq!(projection.farm_setup, FarmSetupProjection::default());
+ assert_eq!(
+ projection.farm_setup_flow_stage,
+ FarmSetupFlowStage::Onboarding
+ );
+ assert_eq!(projection.home_route(), HomeRoute::SetupRequired);
}
#[test]
@@ -554,6 +673,7 @@ mod tests {
);
assert_eq!(store.startup_gate(), AppStartupGate::SetupRequired);
assert_eq!(store.projection().today, TodayAgendaProjection::default());
+ assert_eq!(store.home_route(), HomeRoute::SetupRequired);
}
#[test]
@@ -627,6 +747,7 @@ mod tests {
store.projection().shell.selected_section,
ShellSection::Farmer(FarmerSection::Today)
);
+ assert_eq!(store.home_route(), HomeRoute::FarmSetupOnboarding);
}
#[test]
@@ -797,6 +918,82 @@ mod tests {
}
#[test]
+ fn replace_farm_setup_projection_updates_in_memory_state_without_touching_repository() {
+ let mut store =
+ AppStateStore::load(FailingRepository).expect("failing repository should still load");
+ let farm_setup = FarmSetupProjection::from_draft(FarmSetupDraft::new(
+ "North field farm",
+ "",
+ [FarmOrderMethod::Pickup],
+ ));
+
+ let changed = store.apply(AppStateCommand::replace_farm_setup_projection(
+ farm_setup.clone(),
+ ));
+
+ assert_eq!(changed, Ok(true));
+ assert_eq!(store.farm_setup_projection(), &farm_setup);
+ }
+
+ #[test]
+ fn select_farm_setup_flow_stage_switches_farmer_home_into_form_route() {
+ let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
+ .expect("in-memory repository should load");
+
+ assert_eq!(
+ store.apply(AppStateCommand::replace_identity_projection(
+ ready_identity(ActiveSurface::Farmer),
+ )),
+ Ok(true)
+ );
+ assert_eq!(store.home_route(), HomeRoute::FarmSetupOnboarding);
+
+ let changed = store.apply(AppStateCommand::select_farm_setup_flow_stage(
+ FarmSetupFlowStage::Editing,
+ ));
+
+ assert_eq!(changed, Ok(true));
+ assert_eq!(store.home_route(), HomeRoute::FarmSetupForm);
+ }
+
+ #[test]
+ fn saved_farm_in_today_projection_synchronizes_ready_home_route() {
+ let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
+ .expect("in-memory repository should load");
+ let farm_id = FarmId::new();
+
+ assert_eq!(
+ store.apply(AppStateCommand::replace_identity_projection(
+ ready_identity(ActiveSurface::Farmer),
+ )),
+ Ok(true)
+ );
+
+ let changed = store.apply(AppStateCommand::replace_today_agenda(
+ TodayAgendaProjection {
+ farm: Some(radroots_app_models::FarmSummary {
+ farm_id,
+ display_name: "North field farm".to_owned(),
+ readiness: FarmReadiness::Ready,
+ }),
+ ..TodayAgendaProjection::default()
+ },
+ ));
+
+ assert_eq!(changed, Ok(true));
+ assert_eq!(store.home_route(), HomeRoute::Today);
+ assert_eq!(
+ store
+ .farm_setup_projection()
+ .saved_farm
+ .as_ref()
+ .expect("saved farm")
+ .farm_id,
+ farm_id
+ );
+ }
+
+ #[test]
fn replacing_identity_projection_surfaces_settings_account_state_without_touching_repository() {
let mut store =
AppStateStore::load(FailingRepository).expect("failing repository should still load");