app

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

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:
Mcrates/shared/models/src/lib.rs | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/shared/state/src/lib.rs | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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");