app

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

commit d3179eec10fa50d2b85bcfa51a6cf087d1320ff7
parent 82f50e70d55f538b32f08e70e8bb381a9273538d
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 19:16:13 +0000

state: add pack day host handoff contract

- add typed pack day host handoff kind and status enums in shared models
- add in-memory host handoff request and projection beside pack day export state
- reset stale host handoff state when the pack day window or export state changes
- cover the contract with shared model and state tests

Diffstat:
Mcrates/shared/models/src/lib.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/shared/state/src/lib.rs | 328++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 410 insertions(+), 24 deletions(-)

diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -1609,6 +1609,33 @@ impl PackDayExportArtifactKind { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayHostHandoffKind { + RevealBundle, + OpenPackSheet, +} + +impl PackDayHostHandoffKind { + pub const fn all_v1() -> [Self; 2] { + [Self::RevealBundle, Self::OpenPackSheet] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::RevealBundle => "reveal_bundle", + Self::OpenPackSheet => "open_pack_sheet", + } + } + + pub const fn artifact_kind(self) -> Option<PackDayExportArtifactKind> { + match self { + Self::RevealBundle => None, + Self::OpenPackSheet => Some(PackDayExportArtifactKind::PackSheet), + } + } +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PackDayExportStatus { @@ -1630,6 +1657,27 @@ impl PackDayExportStatus { } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayHostHandoffStatus { + #[default] + Idle, + Running, + Succeeded, + Failed, +} + +impl PackDayHostHandoffStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Running => "running", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PackDayOutputOrderState { @@ -2333,19 +2381,19 @@ mod tests { OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, OrderRecoveryProjection, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, PackDayExportArtifact, PackDayExportArtifactKind, - PackDayExportBundle, PackDayExportStatus, PackDayOutputCustomerOrder, - PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal, - PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, - PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, - ParseStartupSignerSourceError, PersonalEntryProjection, PersonalEntryState, - PersonalSection, PickupLocationId, ProductAttentionState, ProductAvailabilityState, - ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, ProductPricePresentation, - ProductPublishBlocker, ProductStatus, ProductStockState, ProductStockSummary, - ProductsFilter, ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, - RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, RecoveryState, - ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId, - ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, - ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, + PackDayExportBundle, PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, + PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, + PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, + PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, + PackDayScreenQueryState, ParseStartupSignerSourceError, PersonalEntryProjection, + PersonalEntryState, PersonalSection, PickupLocationId, ProductAttentionState, + ProductAvailabilityState, ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, + ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductStockState, + ProductStockSummary, ProductsFilter, ProductsListProjection, ProductsListRow, + ProductsListSummary, ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, + RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, + ReminderId, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, + ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, @@ -2942,7 +2990,7 @@ mod tests { } #[test] - fn pack_day_export_artifact_contract_is_frozen_for_v1() { + fn pack_day_export_and_host_handoff_contracts_are_frozen_for_v1() { assert_eq!( PackDayExportArtifactKind::all_v1(), [ @@ -2971,6 +3019,36 @@ mod tests { assert_eq!(PackDayExportStatus::Running.storage_key(), "running"); assert_eq!(PackDayExportStatus::Succeeded.storage_key(), "succeeded"); assert_eq!(PackDayExportStatus::Failed.storage_key(), "failed"); + assert_eq!( + PackDayHostHandoffKind::all_v1(), + [ + PackDayHostHandoffKind::RevealBundle, + PackDayHostHandoffKind::OpenPackSheet, + ] + ); + assert_eq!( + PackDayHostHandoffKind::RevealBundle.storage_key(), + "reveal_bundle" + ); + assert_eq!( + PackDayHostHandoffKind::OpenPackSheet.storage_key(), + "open_pack_sheet" + ); + assert_eq!(PackDayHostHandoffKind::RevealBundle.artifact_kind(), None); + assert_eq!( + PackDayHostHandoffKind::OpenPackSheet.artifact_kind(), + Some(PackDayExportArtifactKind::PackSheet) + ); + assert_eq!( + PackDayHostHandoffStatus::default(), + PackDayHostHandoffStatus::Idle + ); + assert_eq!(PackDayHostHandoffStatus::Running.storage_key(), "running"); + assert_eq!( + PackDayHostHandoffStatus::Succeeded.storage_key(), + "succeeded" + ); + assert_eq!(PackDayHostHandoffStatus::Failed.storage_key(), "failed"); } #[test] diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -15,11 +15,12 @@ use radroots_app_models::{ FarmSetupReadiness, FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus, - PackDayProjection, PackDayScreenQueryState, PersonalEntryProjection, ProductEditorDraft, - ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, - RecoveryQueueProjection, ReminderFeedProjection, ReminderLogProjection, - SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, - ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayProjection, PackDayScreenQueryState, + PersonalEntryProjection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, + ProductsListProjection, ProductsSort, RecoveryQueueProjection, ReminderFeedProjection, + ReminderLogProjection, SelectedSurfaceProjection, SettingsAccountProjection, + SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, + TodaySetupTaskKind, }; use radroots_app_sync::{ AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, SyncConflict, @@ -309,12 +310,14 @@ pub struct PackDayScreenProjection { pub query: PackDayScreenQueryState, pub projection: PackDayProjection, pub export: PackDayExportProjection, + pub host_handoff: PackDayHostHandoffProjection, } impl PackDayScreenProjection { fn select_fulfillment_window(&mut self, fulfillment_window_id: Option<FulfillmentWindowId>) { if self.query.fulfillment_window_id != fulfillment_window_id { self.export = PackDayExportProjection::default(); + self.host_handoff = PackDayHostHandoffProjection::default(); } self.query.fulfillment_window_id = fulfillment_window_id; } @@ -332,14 +335,22 @@ impl PackDayScreenProjection { if previous_window_id != next_window_id { self.export = PackDayExportProjection::default(); + self.host_handoff = PackDayHostHandoffProjection::default(); } self.projection = projection; } fn replace_export(&mut self, export: PackDayExportProjection) { + if self.export != export { + self.host_handoff = PackDayHostHandoffProjection::default(); + } self.export = export; } + + fn replace_host_handoff(&mut self, host_handoff: PackDayHostHandoffProjection) { + self.host_handoff = host_handoff; + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -394,6 +405,56 @@ impl PackDayExportProjection { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackDayHostHandoffRequest { + pub fulfillment_window_id: FulfillmentWindowId, + pub kind: PackDayHostHandoffKind, + pub bundle_directory: String, +} + +impl PackDayHostHandoffRequest { + pub fn for_bundle(kind: PackDayHostHandoffKind, bundle: &PackDayExportBundle) -> Self { + Self { + fulfillment_window_id: bundle.fulfillment_window_id, + kind, + bundle_directory: bundle.bundle_directory.clone(), + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PackDayHostHandoffProjection { + pub status: PackDayHostHandoffStatus, + pub request: Option<PackDayHostHandoffRequest>, + pub error_message: Option<String>, +} + +impl PackDayHostHandoffProjection { + pub fn running(request: PackDayHostHandoffRequest) -> Self { + Self { + status: PackDayHostHandoffStatus::Running, + request: Some(request), + error_message: None, + } + } + + pub fn succeeded(request: PackDayHostHandoffRequest) -> Self { + Self { + status: PackDayHostHandoffStatus::Succeeded, + request: Some(request), + error_message: None, + } + } + + pub fn failed(request: PackDayHostHandoffRequest, message: impl Into<String>) -> Self { + Self { + status: PackDayHostHandoffStatus::Failed, + request: Some(request), + error_message: Some(message.into()), + } + } +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum FarmSetupFlowStage { #[default] @@ -845,6 +906,13 @@ pub enum AppStateCommand { message: String, }, ResetPackDayExport, + BeginPackDayHostHandoff(PackDayHostHandoffRequest), + SucceedPackDayHostHandoff(PackDayHostHandoffRequest), + FailPackDayHostHandoff { + request: PackDayHostHandoffRequest, + message: String, + }, + ResetPackDayHostHandoff, OpenNewProductEditor, OpenExistingProductEditor { product_id: ProductId, @@ -989,6 +1057,28 @@ impl AppStateCommand { Self::ResetPackDayExport } + pub fn begin_pack_day_host_handoff(request: PackDayHostHandoffRequest) -> Self { + Self::BeginPackDayHostHandoff(request) + } + + pub fn succeed_pack_day_host_handoff(request: PackDayHostHandoffRequest) -> Self { + Self::SucceedPackDayHostHandoff(request) + } + + pub fn fail_pack_day_host_handoff( + request: PackDayHostHandoffRequest, + message: impl Into<String>, + ) -> Self { + Self::FailPackDayHostHandoff { + request, + message: message.into(), + } + } + + pub const fn reset_pack_day_host_handoff() -> Self { + Self::ResetPackDayHostHandoff + } + pub const fn open_new_product_editor() -> Self { Self::OpenNewProductEditor } @@ -1498,6 +1588,26 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap .pack_day .replace_export(PackDayExportProjection::default()); } + AppStateCommand::BeginPackDayHostHandoff(request) => { + projection + .pack_day + .replace_host_handoff(PackDayHostHandoffProjection::running(request)); + } + AppStateCommand::SucceedPackDayHostHandoff(request) => { + projection + .pack_day + .replace_host_handoff(PackDayHostHandoffProjection::succeeded(request)); + } + AppStateCommand::FailPackDayHostHandoff { request, message } => { + projection + .pack_day + .replace_host_handoff(PackDayHostHandoffProjection::failed(request, message)); + } + AppStateCommand::ResetPackDayHostHandoff => { + projection + .pack_day + .replace_host_handoff(PackDayHostHandoffProjection::default()); + } AppStateCommand::OpenNewProductEditor => { projection .products @@ -1831,9 +1941,10 @@ mod tests { AppProjection, AppShellProjection, AppStateCommand, AppStateRepository, AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute, InMemoryAppStateRepository, OrdersScreenProjection, PackDayExportProjection, - PackDayExportRequest, PackDayScreenProjection, PersistedAppState, ProductEditorState, - ProductsScreenProjection, ProductsScreenQueryState, SettingsPreference, - derive_sync_projection, derive_sync_run_status, + PackDayExportRequest, PackDayHostHandoffProjection, PackDayHostHandoffRequest, + PackDayScreenProjection, PersistedAppState, ProductEditorState, ProductsScreenProjection, + ProductsScreenQueryState, SettingsPreference, derive_sync_projection, + derive_sync_run_status, }; use radroots_app_models::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, @@ -1842,7 +1953,8 @@ mod tests { LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, PackDayExportArtifact, - PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus, PackDayPackListRow, + PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus, + PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, PersonalEntryState, PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, ReminderDeliveryState, @@ -1893,6 +2005,14 @@ mod tests { PackDayExportRequest::for_fulfillment_window(fulfillment_window_id) } + fn sample_pack_day_host_handoff_request( + fulfillment_window_id: FulfillmentWindowId, + kind: PackDayHostHandoffKind, + ) -> PackDayHostHandoffRequest { + let bundle = sample_pack_day_export_bundle(fulfillment_window_id); + PackDayHostHandoffRequest::for_bundle(kind, &bundle) + } + fn sample_pack_day_export_bundle( fulfillment_window_id: FulfillmentWindowId, ) -> PackDayExportBundle { @@ -2265,7 +2385,7 @@ mod tests { } #[test] - fn pack_day_export_projection_defaults_to_idle() { + fn pack_day_export_and_host_handoff_projections_default_to_idle() { assert_eq!( PackDayScreenProjection::default().export, PackDayExportProjection { @@ -2275,6 +2395,14 @@ mod tests { error_message: None, } ); + assert_eq!( + PackDayScreenProjection::default().host_handoff, + PackDayHostHandoffProjection { + status: PackDayHostHandoffStatus::Idle, + request: None, + error_message: None, + } + ); } #[test] @@ -2333,6 +2461,64 @@ mod tests { } #[test] + fn pack_day_host_handoff_state_is_restart_ephemeral_and_skips_persistence() { + let mut store = + AppStateStore::load(FailingRepository).expect("failing repository should still load"); + let fulfillment_window_id = FulfillmentWindowId::new(); + let request = sample_pack_day_host_handoff_request( + fulfillment_window_id, + PackDayHostHandoffKind::RevealBundle, + ); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_host_handoff( + request.clone(), + )), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().host_handoff, + PackDayHostHandoffProjection::running(request.clone()) + ); + assert_eq!( + store.persisted_state().seller.pack_day_query, + PackDayScreenQueryState::default() + ); + + assert_eq!( + store.apply(AppStateCommand::succeed_pack_day_host_handoff( + request.clone(), + )), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().host_handoff, + PackDayHostHandoffProjection::succeeded(request.clone()) + ); + + assert_eq!( + store.apply(AppStateCommand::fail_pack_day_host_handoff( + request.clone(), + "finder unavailable", + )), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().host_handoff, + PackDayHostHandoffProjection::failed(request, "finder unavailable") + ); + + assert_eq!( + store.apply(AppStateCommand::reset_pack_day_host_handoff()), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().host_handoff, + PackDayHostHandoffProjection::default() + ); + } + + #[test] fn changing_pack_day_window_clears_stale_export_state() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); @@ -2368,6 +2554,128 @@ mod tests { } #[test] + fn changing_pack_day_window_clears_stale_host_handoff_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let fulfillment_window_id = FulfillmentWindowId::new(); + let next_window_id = FulfillmentWindowId::new(); + let request = sample_pack_day_host_handoff_request( + fulfillment_window_id, + PackDayHostHandoffKind::OpenPackSheet, + ); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_host_handoff(request)), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().host_handoff.status, + PackDayHostHandoffStatus::Running + ); + + assert_eq!( + store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some( + next_window_id, + ))), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().query, + PackDayScreenQueryState { + fulfillment_window_id: Some(next_window_id), + } + ); + assert_eq!( + store.pack_day_projection().host_handoff, + PackDayHostHandoffProjection::default() + ); + } + + #[test] + fn changing_pack_day_export_state_clears_stale_host_handoff_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let fulfillment_window_id = FulfillmentWindowId::new(); + let export_request = sample_pack_day_export_request(fulfillment_window_id); + let host_handoff_request = sample_pack_day_host_handoff_request( + fulfillment_window_id, + PackDayHostHandoffKind::RevealBundle, + ); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_export( + export_request.clone(), + )), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_host_handoff( + host_handoff_request, + )), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().host_handoff.status, + PackDayHostHandoffStatus::Running + ); + + assert_eq!( + store.apply(AppStateCommand::succeed_pack_day_export( + export_request, + sample_pack_day_export_bundle(fulfillment_window_id), + )), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().host_handoff, + PackDayHostHandoffProjection::default() + ); + } + + #[test] + fn replacing_pack_day_projection_with_new_window_clears_stale_host_handoff_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let farm_id = FarmId::new(); + let current_window_id = FulfillmentWindowId::new(); + let next_window_id = FulfillmentWindowId::new(); + let request = sample_pack_day_host_handoff_request( + current_window_id, + PackDayHostHandoffKind::OpenPackSheet, + ); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_host_handoff(request)), + Ok(true) + ); + + let next_pack_day = PackDayProjection { + fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { + fulfillment_window_id: next_window_id, + farm_id, + starts_at: "2026-04-25T16:00:00Z".to_owned(), + ends_at: "2026-04-25T19:00:00Z".to_owned(), + }), + totals_by_product: Vec::new(), + pack_list: Vec::new(), + pickup_roster: Vec::new(), + reminders: ReminderFeedProjection::default(), + }; + + assert_eq!( + store.apply(AppStateCommand::replace_pack_day_projection( + next_pack_day.clone(), + )), + Ok(true) + ); + assert_eq!(store.pack_day_projection().projection, next_pack_day); + assert_eq!( + store.pack_day_projection().host_handoff, + PackDayHostHandoffProjection::default() + ); + } + + #[test] fn startup_identity_choice_flow_is_explicit_and_in_memory_only() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load");