app

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

commit 99e359a5178d9d310344254af40ccb47b312936e
parent 916f6a972a6a38e033175ccf1fb89818abb58032
Author: triesap <tyson@radroots.org>
Date:   Tue, 26 May 2026 19:44:32 +0000

models: split app types and views

Diffstat:
MCargo.lock | 37+++++++++++++++++++++++--------------
MCargo.toml | 6++++--
Mcrates/desktop/Cargo.toml | 2+-
Mcrates/desktop/src/accounts.rs | 30++++++++++++------------------
Mcrates/desktop/src/app.rs | 4++--
Mcrates/desktop/src/pack_day_host_handoff.rs | 8++++----
Mcrates/desktop/src/pack_day_print.rs | 10+++++-----
Mcrates/desktop/src/remote_signer.rs | 20++++++++++----------
Mcrates/desktop/src/runtime.rs | 116++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/desktop/src/window.rs | 98++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/runtime/Cargo.toml | 2+-
Mcrates/runtime/src/pack_day_export.rs | 6+++---
Dcrates/shared/models/Cargo.toml | 16----------------
Dcrates/shared/models/src/lib.rs | 4189-------------------------------------------------------------------------------
Mcrates/state/Cargo.toml | 2+-
Mcrates/state/src/lib.rs | 64++++++++++++++++++++++++++++++++--------------------------------
Mcrates/store/Cargo.toml | 2+-
Mcrates/store/src/activation.rs | 4++--
Mcrates/store/src/activity.rs | 4++--
Mcrates/store/src/buyer.rs | 34+++++++++++++++++-----------------
Mcrates/store/src/farm_rules.rs | 4++--
Mcrates/store/src/farm_setup.rs | 4++--
Mcrates/store/src/lib.rs | 10+++++-----
Mcrates/store/src/local_interop.rs | 16++++++++--------
Mcrates/store/src/orders.rs | 4++--
Mcrates/store/src/products.rs | 4++--
Mcrates/store/src/reminders.rs | 4++--
Mcrates/store/src/sync.rs | 4++--
Mcrates/store/src/today.rs | 12++++++------
Mcrates/sync/Cargo.toml | 2+-
Mcrates/sync/src/lib.rs | 4++--
Mcrates/sync/src/publish.rs | 4++--
Acrates/types/Cargo.toml | 15+++++++++++++++
Acrates/types/src/lib.rs | 990+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/view/Cargo.toml | 19+++++++++++++++++++
Acrates/view/src/lib.rs | 3214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/check.sh | 3++-
37 files changed, 4503 insertions(+), 4464 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5063,12 +5063,12 @@ dependencies = [ "gpui-component-assets", "radroots_app_core", "radroots_app_i18n", - "radroots_app_models", "radroots_app_remote_signer", "radroots_app_sqlite", "radroots_app_state", "radroots_app_sync", "radroots_app_ui", + "radroots_app_view", "radroots_core", "radroots_identity", "radroots_local_events", @@ -5092,7 +5092,7 @@ name = "radroots_app_core" version = "0.1.0" dependencies = [ "chrono", - "radroots_app_models", + "radroots_app_view", "radroots_local_events", "radroots_runtime_paths", "serde", @@ -5111,15 +5111,6 @@ dependencies = [ ] [[package]] -name = "radroots_app_models" -version = "0.1.0" -dependencies = [ - "serde", - "url", - "uuid", -] - -[[package]] name = "radroots_app_remote_signer" version = "0.1.0" dependencies = [ @@ -5138,8 +5129,8 @@ dependencies = [ name = "radroots_app_sqlite" version = "0.1.0" dependencies = [ - "radroots_app_models", "radroots_app_sync", + "radroots_app_view", "radroots_core", "radroots_events", "radroots_events_codec", @@ -5155,8 +5146,8 @@ dependencies = [ name = "radroots_app_state" version = "0.1.0" dependencies = [ - "radroots_app_models", "radroots_app_sync", + "radroots_app_view", "serde", "serde_json", "thiserror 2.0.18", @@ -5167,7 +5158,7 @@ dependencies = [ name = "radroots_app_sync" version = "0.1.0" dependencies = [ - "radroots_app_models", + "radroots_app_view", "radroots_sdk", "serde", "serde_json", @@ -5175,6 +5166,14 @@ dependencies = [ ] [[package]] +name = "radroots_app_types" +version = "0.1.0" +dependencies = [ + "serde", + "uuid", +] + +[[package]] name = "radroots_app_ui" version = "0.1.0" dependencies = [ @@ -5185,6 +5184,16 @@ dependencies = [ ] [[package]] +name = "radroots_app_view" +version = "0.1.0" +dependencies = [ + "radroots_app_types", + "serde", + "url", + "uuid", +] + +[[package]] name = "radroots_core" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "crates/runtime", "crates/i18n", - "crates/shared/models", + "crates/types", + "crates/view", "crates/signer", "crates/store", "crates/state", @@ -44,7 +45,8 @@ radroots_sdk = { path = "../lib/crates/sdk", features = ["relay-client", "signin radroots_sql_core = { path = "../lib/crates/sql_core", features = ["native"] } radroots_app_core = { path = "crates/runtime", version = "0.1.0" } radroots_app_i18n = { path = "crates/i18n", version = "0.1.0" } -radroots_app_models = { path = "crates/shared/models", version = "0.1.0" } +radroots_app_types = { path = "crates/types", version = "0.1.0" } +radroots_app_view = { path = "crates/view", version = "0.1.0" } radroots_app_sqlite = { path = "crates/store", version = "0.1.0" } radroots_app_state = { path = "crates/state", version = "0.1.0" } radroots_app_sync = { path = "crates/sync", version = "0.1.0" } diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml @@ -22,7 +22,7 @@ radroots_secret_vault.workspace = true radroots_sdk.workspace = true radroots_app_core.workspace = true radroots_app_i18n.workspace = true -radroots_app_models.workspace = true +radroots_app_view.workspace = true radroots_app_remote_signer = { path = "../signer" } radroots_app_sqlite.workspace = true radroots_app_state.workspace = true diff --git a/crates/desktop/src/accounts.rs b/crates/desktop/src/accounts.rs @@ -4,11 +4,11 @@ use std::{ }; use radroots_app_core::AppSharedAccountsPaths; -use radroots_app_models::{ +use radroots_app_sqlite::{AppSqliteError, AppSqliteStore}; +use radroots_app_view::{ AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, AppIdentityProjection, FarmId, FarmerActivationProjection, SelectedAccountProjection, SelectedSurfaceProjection, }; -use radroots_app_sqlite::{AppSqliteError, AppSqliteStore}; use radroots_identity::{IdentityError, RadrootsIdentity, RadrootsIdentityId}; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError, @@ -306,7 +306,7 @@ fn account_summary_from_record(record: &RadrootsNostrAccountRecord) -> AccountSu account_id: record.account_id.to_string(), npub: record.public_identity.public_key_npub.clone(), label: record.label.clone(), - custody: radroots_app_models::AccountCustody::LocalManaged, + custody: radroots_app_view::AccountCustody::LocalManaged, } } @@ -360,11 +360,11 @@ mod tests { }; use radroots_app_core::AppSharedAccountsPaths; - use radroots_app_models::{ + use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; + use radroots_app_view::{ AccountSurfaceActivationProjection, ActiveSurface, AppStartupGate, IdentityReadiness, SelectedSurfaceProjection, }; - use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; use radroots_identity::RadrootsIdentity; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, @@ -453,9 +453,7 @@ mod tests { let activation = AccountSurfaceActivationProjection::new( account_id.as_str(), SelectedSurfaceProjection::new(ActiveSurface::Farmer), - radroots_app_models::FarmerActivationProjection::active( - radroots_app_models::FarmId::new(), - ), + radroots_app_view::FarmerActivationProjection::active(radroots_app_view::FarmId::new()), ); sqlite_store .save_surface_activation(&activation) @@ -591,9 +589,7 @@ mod tests { let activation = AccountSurfaceActivationProjection::new( second_account_id.as_str(), SelectedSurfaceProjection::new(ActiveSurface::Farmer), - radroots_app_models::FarmerActivationProjection::active( - radroots_app_models::FarmId::new(), - ), + radroots_app_view::FarmerActivationProjection::active(radroots_app_view::FarmId::new()), ); sqlite_store .save_surface_activation(&activation) @@ -652,9 +648,7 @@ mod tests { let activation = AccountSurfaceActivationProjection::new( first_account_id.as_str(), SelectedSurfaceProjection::new(ActiveSurface::Farmer), - radroots_app_models::FarmerActivationProjection::active( - radroots_app_models::FarmId::new(), - ), + radroots_app_view::FarmerActivationProjection::active(radroots_app_view::FarmId::new()), ); sqlite_store .save_surface_activation(&activation) @@ -702,8 +696,8 @@ mod tests { .save_surface_activation(&AccountSurfaceActivationProjection::new( first_account_id.as_str(), SelectedSurfaceProjection::new(ActiveSurface::Farmer), - radroots_app_models::FarmerActivationProjection::active( - radroots_app_models::FarmId::new(), + radroots_app_view::FarmerActivationProjection::active( + radroots_app_view::FarmId::new(), ), )) .expect("first activation should save"); @@ -711,8 +705,8 @@ mod tests { .save_surface_activation(&AccountSurfaceActivationProjection::new( second_account_id.as_str(), SelectedSurfaceProjection::new(ActiveSurface::Farmer), - radroots_app_models::FarmerActivationProjection::active( - radroots_app_models::FarmId::new(), + radroots_app_view::FarmerActivationProjection::active( + radroots_app_view::FarmId::new(), ), )) .expect("second activation should save"); diff --git a/crates/desktop/src/app.rs b/crates/desktop/src/app.rs @@ -148,11 +148,11 @@ mod tests { APP_PROJECTION_SOURCE, AppBuildIdentity, AppRuntimeCapture, AppRuntimeMode, AppRuntimeSnapshot, }; - use radroots_app_models::{ + use radroots_app_state::{AppShellProjection, FarmWorkspaceReadinessProjection, HomeRoute}; + use radroots_app_view::{ AppStartupGate, LoggedOutStartupProjection, SettingsAccountProjection, TodayAgendaProjection, }; - use radroots_app_state::{AppShellProjection, FarmWorkspaceReadinessProjection, HomeRoute}; use tracing::{ Event, Level, Subscriber, field::{Field, Visit}, diff --git a/crates/desktop/src/pack_day_host_handoff.rs b/crates/desktop/src/pack_day_host_handoff.rs @@ -3,7 +3,7 @@ use std::path::{Component, Path, PathBuf}; #[cfg(target_os = "macos")] use std::process::Command; -use radroots_app_models::{PackDayExportArtifactKind, PackDayExportBundle, PackDayHostHandoffKind}; +use radroots_app_view::{PackDayExportArtifactKind, PackDayExportBundle, PackDayHostHandoffKind}; use thiserror::Error; #[derive(Clone, Debug, Eq, PartialEq)] @@ -310,7 +310,7 @@ mod tests { PackDayHostHandoffCommandResult, PackDayHostHandoffError, execute_pack_day_host_handoff_plan_with, plan_pack_day_host_handoff, }; - use radroots_app_models::{ + use radroots_app_view::{ PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, PackDayHostHandoffKind, }; @@ -346,8 +346,8 @@ mod tests { fn sample_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { PackDayExportBundle { - fulfillment_window_id: radroots_app_models::FulfillmentWindowId::new(), - export_instance_id: radroots_app_models::PackDayExportInstanceId::new(), + fulfillment_window_id: radroots_app_view::FulfillmentWindowId::new(), + export_instance_id: radroots_app_view::PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: bundle_directory.to_string_lossy().into_owned(), artifacts: vec![ diff --git a/crates/desktop/src/pack_day_print.rs b/crates/desktop/src/pack_day_print.rs @@ -5,12 +5,12 @@ use std::path::{Component, Path, PathBuf}; #[cfg(target_os = "macos")] use std::process::Command; -use radroots_app_models::{ +use radroots_app_state::PackDayBatchPrintRequest; +use radroots_app_view::{ PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintLabelStock, }; -use radroots_app_state::PackDayBatchPrintRequest; use thiserror::Error; const CUSTOMER_LABEL_PREPARED_ASSET_ROOT: &str = "radroots_app_pack_day_print"; @@ -898,12 +898,12 @@ mod tests { plan_pack_day_batch_print, plan_pack_day_print, prepared_customer_label_asset_directory, prepared_customer_label_asset_path, prepared_customer_label_asset_root, }; - use radroots_app_models::{ + use radroots_app_state::PackDayBatchPrintRequest; + use radroots_app_view::{ PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, PackDayPrintKind, PackDayPrintLabelStock, }; - use radroots_app_state::PackDayBatchPrintRequest; use std::fs; use std::io; use std::path::PathBuf; @@ -934,7 +934,7 @@ mod tests { fn sample_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { PackDayExportBundle { - fulfillment_window_id: radroots_app_models::FulfillmentWindowId::new(), + fulfillment_window_id: radroots_app_view::FulfillmentWindowId::new(), export_instance_id: PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: bundle_directory.to_string_lossy().into_owned(), diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs @@ -3,13 +3,13 @@ use std::fs; use std::path::{Path, PathBuf}; use radroots_app_core::AppDesktopRuntimePaths; -use radroots_app_models::{AccountCustody, AppIdentityProjection}; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerError, RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStatus, RadrootsAppRemoteSignerSessionStoreLoadResult, RadrootsAppRemoteSignerSessionStoreState, }; +use radroots_app_view::{AccountCustody, AppIdentityProjection}; use radroots_identity::{IdentityError, RadrootsIdentityId}; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountsError, RadrootsNostrAccountsManager, @@ -503,22 +503,22 @@ mod tests { assert_eq!(selected.label.as_deref(), Some("remote signer")); let projection = apply_remote_signer_custody( - radroots_app_models::AppIdentityProjection::ready( - vec![radroots_app_models::AccountSummary { + radroots_app_view::AppIdentityProjection::ready( + vec![radroots_app_view::AccountSummary { account_id: approved.user_identity.id.to_string(), npub: approved.user_identity.public_key_npub.clone(), label: Some("remote signer".to_owned()), - custody: radroots_app_models::AccountCustody::LocalManaged, + custody: radroots_app_view::AccountCustody::LocalManaged, }], - radroots_app_models::SelectedAccountProjection::new( - radroots_app_models::AccountSummary { + radroots_app_view::SelectedAccountProjection::new( + radroots_app_view::AccountSummary { account_id: approved.user_identity.id.to_string(), npub: approved.user_identity.public_key_npub.clone(), label: Some("remote signer".to_owned()), - custody: radroots_app_models::AccountCustody::LocalManaged, + custody: radroots_app_view::AccountCustody::LocalManaged, }, - radroots_app_models::SelectedSurfaceProjection::default(), - radroots_app_models::FarmerActivationProjection::inactive(), + radroots_app_view::SelectedSurfaceProjection::default(), + radroots_app_view::FarmerActivationProjection::inactive(), ), ), &paths, @@ -531,7 +531,7 @@ mod tests { .expect("selected") .account .custody, - radroots_app_models::AccountCustody::RemoteSigner + radroots_app_view::AccountCustody::RemoteSigner ); } diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -12,24 +12,6 @@ use radroots_app_core::{ prepare_pack_day_export_bundle_at_data_root, shared_local_events_database_path_from_shared_accounts, write_prepared_pack_day_export_bundle, }; -use radroots_app_models::{ - ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, - BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, - BuyerCheckoutDraft, BuyerContext, BuyerOrderDetailProjection, BuyerProductDetailProjection, - FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, - FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, - LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrderRecoveryProjection, - OrderStatus, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, - PackDayBatchPrintStatus, PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus, - PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPrintKind, PackDayPrintStatus, - PackDayProjection, PackDayScreenQueryState, PersonalSection, PickupLocationRecord, - ProductEditorDraft, ProductId, ProductStatus, ProductsFilter, ProductsListProjection, - ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, RecoveryState, - ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId, - ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, - ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, -}; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, }; @@ -57,6 +39,24 @@ use radroots_app_sync::{ AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, SyncAggregateRef, SyncCheckpointStatus, SyncConflictSeverity, SyncOperationKind, SyncTrigger, }; +use radroots_app_view::{ + ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, + BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, + BuyerCheckoutDraft, BuyerContext, BuyerOrderDetailProjection, BuyerProductDetailProjection, + FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, + FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, + LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrderRecoveryProjection, + OrderStatus, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, + PackDayBatchPrintStatus, PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus, + PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPrintKind, PackDayPrintStatus, + PackDayProjection, PackDayScreenQueryState, PersonalSection, PickupLocationRecord, + ProductEditorDraft, ProductId, ProductStatus, ProductsFilter, ProductsListProjection, + ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, RecoveryState, + ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId, + ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, + ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, + TodayAgendaProjection, +}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, @@ -4223,7 +4223,7 @@ impl DesktopAppRuntimeState { fn selected_account_for_farm_setup( &self, - ) -> Result<&radroots_app_models::SelectedAccountProjection, DesktopAppRuntimeFarmSetupError> + ) -> Result<&radroots_app_view::SelectedAccountProjection, DesktopAppRuntimeFarmSetupError> { self.state_store .identity_projection() @@ -4234,7 +4234,7 @@ impl DesktopAppRuntimeState { fn selected_account_for_farm_rules( &self, - ) -> Result<&radroots_app_models::SelectedAccountProjection, DesktopAppRuntimeFarmRulesError> + ) -> Result<&radroots_app_view::SelectedAccountProjection, DesktopAppRuntimeFarmRulesError> { self.state_store .identity_projection() @@ -4343,7 +4343,7 @@ impl DesktopAppRuntimeState { fn load_personal_listings_for_query( &self, query: &BuyerSearchScreenQueryState, - ) -> Result<radroots_app_models::BuyerListingsProjection, AppSqliteError> { + ) -> Result<radroots_app_view::BuyerListingsProjection, AppSqliteError> { let _ = self.import_shared_local_events()?; let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(Default::default()); @@ -4355,7 +4355,7 @@ impl DesktopAppRuntimeState { fn refresh_personal_cart_and_checkout( &mut self, refreshed_cart: BuyerCartProjection, - refreshed_checkout: radroots_app_models::BuyerCheckoutProjection, + refreshed_checkout: radroots_app_view::BuyerCheckoutProjection, ) -> bool { self.mutate_personal_projection(|projection| { let mut changed = false; @@ -4654,7 +4654,7 @@ impl DesktopAppRuntimeState { fn append_app_farm_local_work_record( &self, - account: &radroots_app_models::SelectedAccountProjection, + account: &radroots_app_view::SelectedAccountProjection, projection: &FarmSetupProjection, saved_farm: &FarmSummary, ) -> Result<Option<String>, AppSqliteError> { @@ -5105,7 +5105,7 @@ impl DesktopAppRuntimeState { fn local_events_owner_pubkey( &self, - account: &radroots_app_models::SelectedAccountProjection, + account: &radroots_app_view::SelectedAccountProjection, ) -> Option<String> { if is_hex_64(account.account.account_id.as_str()) { return Some(account.account.account_id.clone()); @@ -5124,7 +5124,7 @@ impl DesktopAppRuntimeState { fn selected_buyer_account( &self, buyer_context: &BuyerContext, - ) -> Option<&radroots_app_models::SelectedAccountProjection> { + ) -> Option<&radroots_app_view::SelectedAccountProjection> { let BuyerContext::Account(account_id) = buyer_context else { return None; }; @@ -8324,24 +8324,6 @@ mod tests { AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, AppSharedAccountsPaths, SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, }; - use radroots_app_models::{ - AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, - AppActivityKind, AppIdentityProjection, AppStartupGate, BlackoutPeriodId, - BlackoutPeriodRecord, BuyerCheckoutDisabledReason, BuyerCheckoutDraft, BuyerOrderStatus, - FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness, - FarmReadinessBlocker, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, - FarmSummary, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, - FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, - PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, - PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, - PackDayHostHandoffStatus, PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, - PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, - PersonalSection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductId, - ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsSort, RecoveryKind, - RecoveryRecordId, ReminderDeliveryState, ReminderFeedProjection, ReminderKind, - SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, - ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, - }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, }; @@ -8365,6 +8347,24 @@ mod tests { SyncCheckpointStatus, SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncOperationKind, SyncTrigger, }; + use radroots_app_view::{ + AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, + AppActivityKind, AppIdentityProjection, AppStartupGate, BlackoutPeriodId, + BlackoutPeriodRecord, BuyerCheckoutDisabledReason, BuyerCheckoutDraft, BuyerOrderStatus, + FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness, + FarmReadinessBlocker, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, + FarmSummary, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, + FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, + PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, + PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, + PackDayHostHandoffStatus, PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, + PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, + PersonalSection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductId, + ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsSort, RecoveryKind, + RecoveryRecordId, ReminderDeliveryState, ReminderFeedProjection, ReminderKind, + SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + }; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; @@ -9620,7 +9620,7 @@ mod tests { assert_eq!( summary.logged_out_startup.phase, - radroots_app_models::LoggedOutStartupPhase::GenerateKeyStarting + radroots_app_view::LoggedOutStartupPhase::GenerateKeyStarting ); assert_eq!( summary.logged_out_startup.signer_entry.source_input, @@ -11459,7 +11459,7 @@ mod tests { assert!(runtime.begin_generate_key_startup()); assert_eq!( runtime.summary().logged_out_startup.phase, - radroots_app_models::LoggedOutStartupPhase::GenerateKeyStarting + radroots_app_view::LoggedOutStartupPhase::GenerateKeyStarting ); cleanup_remote_signer_paths(&paths); @@ -11484,7 +11484,7 @@ mod tests { assert_eq!( restarted.summary().logged_out_startup.phase, - radroots_app_models::LoggedOutStartupPhase::SignerEntry + radroots_app_view::LoggedOutStartupPhase::SignerEntry ); assert_eq!( restored.record.client_account_id(), @@ -11518,7 +11518,7 @@ mod tests { assert_eq!( restarted.summary().logged_out_startup.phase, - radroots_app_models::LoggedOutStartupPhase::ContinuePrompt + radroots_app_view::LoggedOutStartupPhase::ContinuePrompt ); assert!( restarted @@ -11546,7 +11546,7 @@ mod tests { assert_eq!( summary.logged_out_startup.phase, - radroots_app_models::LoggedOutStartupPhase::SignerEntry + radroots_app_view::LoggedOutStartupPhase::SignerEntry ); assert_eq!( summary.logged_out_startup.signer_entry.source_input, @@ -11567,7 +11567,7 @@ mod tests { assert_eq!( restarted.summary().logged_out_startup.phase, - radroots_app_models::LoggedOutStartupPhase::IdentityChoice + radroots_app_view::LoggedOutStartupPhase::IdentityChoice ); cleanup_bootstrapped_runtime_paths(&paths); @@ -11863,12 +11863,12 @@ mod tests { let cloned_runtime = runtime.clone(); let today_agenda = TodayAgendaProjection { farm: Some(FarmSummary { - farm_id: radroots_app_models::FarmId::new(), + farm_id: radroots_app_view::FarmId::new(), display_name: "North field farm".to_owned(), readiness: FarmReadiness::Incomplete, }), summary: Some(TodaySummary { - farm_id: radroots_app_models::FarmId::new(), + farm_id: radroots_app_view::FarmId::new(), orders_needing_action: 2, low_stock_products: 1, draft_products: 3, @@ -11894,7 +11894,7 @@ mod tests { assert_eq!(summary.home_route, HomeRoute::SetupRequired); assert_eq!( summary.shell_projection.active_surface, - radroots_app_models::ActiveSurface::Personal + radroots_app_view::ActiveSurface::Personal ); assert_eq!( summary.shell_projection.selected_section, @@ -11919,7 +11919,7 @@ mod tests { assert_eq!( summary.shell_projection.active_surface, - radroots_app_models::ActiveSurface::Personal + radroots_app_view::ActiveSurface::Personal ); assert_eq!( summary.shell_projection.selected_section, @@ -12207,7 +12207,7 @@ mod tests { ); assert_eq!( summary.personal_projection.entry.state, - radroots_app_models::PersonalEntryState::Guest + radroots_app_view::PersonalEntryState::Guest ); } @@ -15929,7 +15929,7 @@ mod tests { price_currency: "usd".to_owned(), stock_quantity: Some(14), availability_window_id: None, - status: radroots_app_models::ProductStatus::Published, + status: radroots_app_view::ProductStatus::Published, }; assert!( @@ -16422,7 +16422,7 @@ mod tests { let blackout_period_id = BlackoutPeriodId::new(); let saved_projection = runtime - .save_farm_rules_projection(radroots_app_models::FarmRulesProjection { + .save_farm_rules_projection(radroots_app_view::FarmRulesProjection { farm_profile: Some(FarmProfileRecord { farm_id, display_name: "Harbor farm".to_owned(), @@ -17880,8 +17880,8 @@ mod tests { status: &str, stock_count: Option<u32>, updated_at: &str, - ) -> radroots_app_models::ProductId { - let product_id = radroots_app_models::ProductId::new(); + ) -> radroots_app_view::ProductId { + let product_id = radroots_app_view::ProductId::new(); let stock_count = stock_count .map(|value| value.to_string()) .unwrap_or_else(|| "null".to_owned()); diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -9,28 +9,6 @@ use gpui_component::{ input::{InputEvent, InputState}, }; use radroots_app_i18n::{AppTextKey, app_text}; -pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; -use radroots_app_models::{ - AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, BuyerCartProjection, - BuyerCartReplaceConfirmationProjection, BuyerCheckoutDraft, BuyerCheckoutSummaryProjection, - BuyerListingRow, BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, - BuyerProductDetailProjection, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, - FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, - FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, FarmerSection, - FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary, LoggedOutStartupPhase, - OrderDetailItemRow, OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, - OrderRecoveryProjection, OrderStatus, OrdersFilter, OrdersListRow, - PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, PackDayExportBundle, - PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, - PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, - PackDayRosterRow, PersonalEntryState, PersonalSection, PickupLocationId, PickupLocationRecord, - ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPricePresentation, - ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, - RecoveryKind, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, - ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, - RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, TodayAgendaProjection, - TodaySetupTaskKind, -}; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSource, @@ -67,6 +45,28 @@ use radroots_app_ui::{ app_text_label as home_farm_setup_field_label, app_text_value, label_value_list, runtime_metadata_rows, utility_title_row, }; +pub use radroots_app_view::SettingsSection as SettingsPanelViewKey; +use radroots_app_view::{ + AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, BuyerCartProjection, + BuyerCartReplaceConfirmationProjection, BuyerCheckoutDraft, BuyerCheckoutSummaryProjection, + BuyerListingRow, BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, + BuyerProductDetailProjection, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, + FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, + FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, FarmerSection, + FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary, LoggedOutStartupPhase, + OrderDetailItemRow, OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, + OrderRecoveryProjection, OrderStatus, OrdersFilter, OrdersListRow, + PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, PackDayExportBundle, + PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, + PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, + PackDayRosterRow, PersonalEntryState, PersonalSection, PickupLocationId, PickupLocationRecord, + ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPricePresentation, + ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, + RecoveryKind, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, + ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, + RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, TodayAgendaProjection, + TodaySetupTaskKind, +}; use radroots_nostr::prelude::RadrootsNostrClient; use std::{ collections::BTreeSet, @@ -1185,7 +1185,7 @@ impl HomeView { fn switch_to_marketplace(&mut self, cx: &mut Context<Self>) { match self .runtime - .select_active_surface(radroots_app_models::ActiveSurface::Personal) + .select_active_surface(radroots_app_view::ActiveSurface::Personal) { Ok(true) => { self.products_stock_editor = None; @@ -1207,7 +1207,7 @@ impl HomeView { fn switch_to_farmer_workspace(&mut self, cx: &mut Context<Self>) { match self .runtime - .select_active_surface(radroots_app_models::ActiveSurface::Farmer) + .select_active_surface(radroots_app_view::ActiveSurface::Farmer) { Ok(true) => { self.products_stock_editor = None; @@ -7877,7 +7877,7 @@ fn shared_shell_header( ) -> impl IntoElement { let can_enter_farmer_workspace = runtime.personal_projection.entry.can_enter_farmer_workspace; let is_marketplace_active = - runtime.shell_projection.active_surface != radroots_app_models::ActiveSurface::Farmer; + runtime.shell_projection.active_surface != radroots_app_view::ActiveSurface::Farmer; let farm_name = home_saved_farm(runtime).map(|farm| farm.display_name.clone()); let account_label = shell_account_label(runtime); @@ -8379,7 +8379,7 @@ fn buyer_cart_card( fn buyer_cart_line_card( index: usize, - line: &radroots_app_models::BuyerCartLineProjection, + line: &radroots_app_view::BuyerCartLineProjection, cx: &mut Context<HomeView>, ) -> impl IntoElement { app_surface_panel( @@ -8433,7 +8433,7 @@ fn buyer_cart_line_card( fn buyer_checkout_card( form: &BuyerCheckoutFormState, - checkout: &radroots_app_models::BuyerCheckoutProjection, + checkout: &radroots_app_view::BuyerCheckoutProjection, on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_place_order: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, @@ -12582,7 +12582,7 @@ fn home_status_row(status: &HomeStatusPresentation) -> impl IntoElement { ) } -fn home_summary_card(summary: &radroots_app_models::TodaySummary) -> impl IntoElement { +fn home_summary_card(summary: &radroots_app_view::TodaySummary) -> impl IntoElement { home_card( app_shared_text(AppTextKey::HomeTodayTitle), div() @@ -12819,7 +12819,7 @@ fn home_draft_row(product: &ProductListRow) -> AnyElement { .into_any_element() } -fn home_setup_task_row(task: &radroots_app_models::TodaySetupTask) -> AnyElement { +fn home_setup_task_row(task: &radroots_app_view::TodaySetupTask) -> AnyElement { let is_complete = task.is_complete; div() @@ -13047,21 +13047,6 @@ mod tests { use radroots_app_core::{ AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, }; - use radroots_app_models::SettingsAccountProjection; - use radroots_app_models::{ - ActiveSurface, AppStartupGate, BuyerOrderDetailProjection, BuyerOrderStatus, - BuyerOrdersListRow, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, - FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, - FulfillmentWindowSummary, LoggedOutStartupPhase, LoggedOutStartupProjection, - OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersListRow, - PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifact, - PackDayExportArtifactKind, PackDayExportBundle, PackDayHostHandoffKind, - PackDayHostHandoffStatus, PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, - PackDayProductTotalRow, PackDayProjection, PersonalSection, ProductId, - ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderKind, - ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, - ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, - }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -13076,6 +13061,21 @@ mod tests { AppSyncProjection, AppSyncRunStatus, SyncAggregateRef, SyncCheckpointStatus, SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncConflictStatus, }; + use radroots_app_view::SettingsAccountProjection; + use radroots_app_view::{ + ActiveSurface, AppStartupGate, BuyerOrderDetailProjection, BuyerOrderStatus, + BuyerOrdersListRow, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, + FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, + FulfillmentWindowSummary, LoggedOutStartupPhase, LoggedOutStartupProjection, + OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersListRow, + PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifact, + PackDayExportArtifactKind, PackDayExportBundle, PackDayHostHandoffKind, + PackDayHostHandoffStatus, PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, + PackDayProductTotalRow, PackDayProjection, PersonalSection, ProductId, + ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderKind, + ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + }; use radroots_identity::RadrootsIdentity; use std::{ fs, @@ -13251,7 +13251,7 @@ mod tests { fn sample_pack_day_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { PackDayExportBundle { fulfillment_window_id: FulfillmentWindowId::new(), - export_instance_id: radroots_app_models::PackDayExportInstanceId::new(), + export_instance_id: radroots_app_view::PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: bundle_directory.to_string_lossy().into_owned(), artifacts: vec![ @@ -13900,7 +13900,7 @@ mod tests { let fulfillment_window_id = FulfillmentWindowId::new(); let bundle = PackDayExportBundle { fulfillment_window_id, - export_instance_id: radroots_app_models::PackDayExportInstanceId::new(), + export_instance_id: radroots_app_view::PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), artifacts: vec![ @@ -14779,7 +14779,7 @@ mod tests { #[test] fn reminder_action_target_prefers_order_detail_before_pack_day() { - let order_id = radroots_app_models::OrderId::new(); + let order_id = radroots_app_view::OrderId::new(); let fulfillment_window_id = FulfillmentWindowId::new(); assert_eq!( @@ -14959,7 +14959,7 @@ mod tests { let review_conflict = DesktopAppSyncConflictSummary { conflict_id: String::new(), conflict: SyncConflict { - aggregate: SyncAggregateRef::Order(radroots_app_models::OrderId::new()), + aggregate: SyncAggregateRef::Order(radroots_app_view::OrderId::new()), kind: SyncConflictKind::RemoteValidationReject, severity: SyncConflictSeverity::ReviewRequired, resolution: SyncConflictResolutionStatus::Unresolved, @@ -15158,7 +15158,7 @@ mod tests { } fn fixture_reminder( - order_id: Option<radroots_app_models::OrderId>, + order_id: Option<radroots_app_view::OrderId>, fulfillment_window_id: Option<FulfillmentWindowId>, kind: ReminderKind, urgency: ReminderUrgency, diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies] chrono.workspace = true -radroots_app_models.workspace = true +radroots_app_view.workspace = true radroots_local_events.workspace = true radroots_runtime_paths.workspace = true serde.workspace = true diff --git a/crates/runtime/src/pack_day_export.rs b/crates/runtime/src/pack_day_export.rs @@ -4,7 +4,7 @@ use std::{ }; use chrono::{DateTime, Utc}; -use radroots_app_models::{ +use radroots_app_view::{ PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, PackDayOutputSource, }; @@ -247,7 +247,7 @@ fn finalize_export_lines(lines: Vec<String>) -> String { format!("{}\n", lines.join("\n")) } -fn format_quantity(quantity: &radroots_app_models::PackDayOutputQuantity) -> String { +fn format_quantity(quantity: &radroots_app_view::PackDayOutputQuantity) -> String { let unit_label = quantity.unit_label.trim(); if unit_label.is_empty() { quantity.value.to_string() @@ -265,7 +265,7 @@ mod tests { }; use chrono::{TimeZone, Utc}; - use radroots_app_models::{ + use radroots_app_view::{ FarmId, FulfillmentWindowId, OrderId, PackDayExportArtifactKind, PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, diff --git a/crates/shared/models/Cargo.toml b/crates/shared/models/Cargo.toml @@ -1,16 +0,0 @@ -[package] -name = "radroots_app_models" -version.workspace = true -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license.workspace = true -publish = false - -[dependencies] -serde.workspace = true -url = "2" -uuid.workspace = true - -[lints] -workspace = true diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -1,4189 +0,0 @@ -#![forbid(unsafe_code)] - -use serde::{Deserialize, Serialize}; -use std::{collections::BTreeSet, error::Error, fmt, str::FromStr}; -use url::Url; -use uuid::Uuid; - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ActiveSurface { - #[default] - Farmer, - Personal, -} - -impl ActiveSurface { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Farmer => "farmer", - Self::Personal => "personal", - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FarmerSection { - #[default] - Today, - Products, - Orders, - PackDay, - Farm, -} - -impl FarmerSection { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Today => "farmer.today", - Self::Products => "farmer.products", - Self::Orders => "farmer.orders", - Self::PackDay => "farmer.pack_day", - Self::Farm => "farmer.farm", - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PersonalSection { - #[default] - Browse, - Search, - Cart, - Orders, -} - -impl PersonalSection { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Browse => "personal.browse", - Self::Search => "personal.search", - Self::Cart => "personal.cart", - Self::Orders => "personal.orders", - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SettingsSection { - #[default] - Account, - Farm, - Settings, - About, -} - -impl SettingsSection { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Account => "settings.account", - Self::Farm => "settings.farm", - Self::Settings => "settings.settings", - Self::About => "settings.about", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SettingsPreference { - AllowRelayConnections, - UseMediaServers, - UseNip05, - LaunchAtLogin, -} - -impl SettingsPreference { - pub const fn storage_key(self) -> &'static str { - match self { - Self::AllowRelayConnections => "allow_relay_connections", - Self::UseMediaServers => "use_media_servers", - Self::UseNip05 => "use_nip05", - Self::LaunchAtLogin => "launch_at_login", - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(tag = "surface", content = "section", rename_all = "snake_case")] -pub enum ShellSection { - #[default] - Home, - Personal(PersonalSection), - Farmer(FarmerSection), - Settings(SettingsSection), -} - -impl ShellSection { - pub const fn surface(self) -> Option<ActiveSurface> { - match self { - Self::Home | Self::Settings(_) => None, - Self::Personal(_) => Some(ActiveSurface::Personal), - Self::Farmer(_) => Some(ActiveSurface::Farmer), - } - } - - pub const fn default_for_surface(surface: ActiveSurface) -> Self { - match surface { - ActiveSurface::Personal => Self::Personal(PersonalSection::Browse), - ActiveSurface::Farmer => Self::Farmer(FarmerSection::Today), - } - } - - pub const fn storage_key(self) -> &'static str { - match self { - Self::Home => "home", - Self::Personal(section) => section.storage_key(), - Self::Farmer(section) => section.storage_key(), - Self::Settings(section) => section.storage_key(), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ParseShellSectionError; - -impl fmt::Display for ParseShellSectionError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("invalid shell section key") - } -} - -impl Error for ParseShellSectionError {} - -impl FromStr for ShellSection { - type Err = ParseShellSectionError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - match value { - "home" => Ok(Self::Home), - "personal.browse" => Ok(Self::Personal(PersonalSection::Browse)), - "personal.search" => Ok(Self::Personal(PersonalSection::Search)), - "personal.cart" => Ok(Self::Personal(PersonalSection::Cart)), - "personal.orders" => Ok(Self::Personal(PersonalSection::Orders)), - "farmer.today" => Ok(Self::Farmer(FarmerSection::Today)), - "farmer.products" => Ok(Self::Farmer(FarmerSection::Products)), - "farmer.orders" => Ok(Self::Farmer(FarmerSection::Orders)), - "farmer.pack_day" => Ok(Self::Farmer(FarmerSection::PackDay)), - "farmer.farm" => Ok(Self::Farmer(FarmerSection::Farm)), - "settings.account" => Ok(Self::Settings(SettingsSection::Account)), - "settings.farm" => Ok(Self::Settings(SettingsSection::Farm)), - "settings.settings" => Ok(Self::Settings(SettingsSection::Settings)), - "settings.about" => Ok(Self::Settings(SettingsSection::About)), - _ => Err(ParseShellSectionError), - } - } -} - -macro_rules! typed_id { - ($name:ident) => { - #[derive( - Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize, - )] - #[serde(transparent)] - pub struct $name(Uuid); - - impl $name { - pub fn new() -> Self { - Self(Uuid::now_v7()) - } - - pub fn as_uuid(self) -> Uuid { - self.0 - } - } - - impl From<Uuid> for $name { - fn from(value: Uuid) -> Self { - Self(value) - } - } - - impl From<$name> for Uuid { - fn from(value: $name) -> Self { - value.0 - } - } - - impl fmt::Display for $name { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) - } - } - - impl FromStr for $name { - type Err = uuid::Error; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - Uuid::parse_str(value).map(Self) - } - } - - impl TryFrom<&str> for $name { - type Error = uuid::Error; - - fn try_from(value: &str) -> Result<Self, Self::Error> { - value.parse() - } - } - }; -} - -typed_id!(FarmId); -typed_id!(PickupLocationId); -typed_id!(BlackoutPeriodId); -typed_id!(ProductId); -typed_id!(OrderId); -typed_id!(FulfillmentWindowId); -typed_id!(PackDayExportInstanceId); -typed_id!(ActivityEventId); -typed_id!(ReminderId); -typed_id!(RecoveryRecordId); - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AccountCustody { - LocalManaged, - BrowserSigner, - RemoteSigner, -} - -impl AccountCustody { - pub const fn storage_key(self) -> &'static str { - match self { - Self::LocalManaged => "local_managed", - Self::BrowserSigner => "browser_signer", - Self::RemoteSigner => "remote_signer", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum IdentityBlockedReason { - RuntimeUnavailable, - HostVaultUnavailable, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(tag = "status", content = "reason", rename_all = "snake_case")] -pub enum IdentityReadiness { - #[default] - MissingAccount, - Ready, - Blocked(IdentityBlockedReason), -} - -impl IdentityReadiness { - pub const fn storage_key(self) -> &'static str { - match self { - Self::MissingAccount => "missing_account", - Self::Ready => "ready", - Self::Blocked(IdentityBlockedReason::RuntimeUnavailable) => "runtime_unavailable", - Self::Blocked(IdentityBlockedReason::HostVaultUnavailable) => "host_vault_unavailable", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct SelectedSurfaceProjection { - pub active_surface: ActiveSurface, -} - -impl Default for SelectedSurfaceProjection { - fn default() -> Self { - Self::new(ActiveSurface::Personal) - } -} - -impl SelectedSurfaceProjection { - pub const fn new(active_surface: ActiveSurface) -> Self { - Self { active_surface } - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct FarmerActivationProjection { - pub farm_id: Option<FarmId>, -} - -impl FarmerActivationProjection { - pub const fn inactive() -> Self { - Self { farm_id: None } - } - - pub fn active(farm_id: FarmId) -> Self { - Self { - farm_id: Some(farm_id), - } - } - - pub const fn is_active(&self) -> bool { - self.farm_id.is_some() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct AccountSummary { - pub account_id: String, - pub npub: String, - pub label: Option<String>, - pub custody: AccountCustody, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct AccountSurfaceActivationProjection { - pub account_id: String, - pub selected_surface: SelectedSurfaceProjection, - pub farmer_activation: FarmerActivationProjection, -} - -impl AccountSurfaceActivationProjection { - pub fn new( - account_id: impl Into<String>, - selected_surface: SelectedSurfaceProjection, - farmer_activation: FarmerActivationProjection, - ) -> Self { - let active_surface = if farmer_activation.is_active() { - selected_surface.active_surface - } else { - ActiveSurface::Personal - }; - - Self { - account_id: account_id.into(), - selected_surface: SelectedSurfaceProjection::new(active_surface), - farmer_activation, - } - } - - pub const fn active_surface(&self) -> ActiveSurface { - self.selected_surface.active_surface - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct SelectedAccountProjection { - pub account: AccountSummary, - pub selected_surface: SelectedSurfaceProjection, - pub farmer_activation: FarmerActivationProjection, -} - -impl SelectedAccountProjection { - pub fn new( - account: AccountSummary, - selected_surface: SelectedSurfaceProjection, - farmer_activation: FarmerActivationProjection, - ) -> Self { - let active_surface = if farmer_activation.is_active() { - selected_surface.active_surface - } else { - ActiveSurface::Personal - }; - - Self { - account, - selected_surface: SelectedSurfaceProjection::new(active_surface), - farmer_activation, - } - } - - pub fn from_surface_activation( - account: AccountSummary, - activation: AccountSurfaceActivationProjection, - ) -> Self { - Self::new( - account, - activation.selected_surface, - activation.farmer_activation, - ) - } - - pub const fn active_surface(&self) -> ActiveSurface { - self.selected_surface.active_surface - } -} - -impl From<&SelectedAccountProjection> for AccountSurfaceActivationProjection { - fn from(value: &SelectedAccountProjection) -> Self { - Self::new( - value.account.account_id.clone(), - value.selected_surface, - value.farmer_activation.clone(), - ) - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AppStartupGate { - Blocked, - #[default] - SetupRequired, - Personal, - Farmer, -} - -impl AppStartupGate { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Blocked => "blocked", - Self::SetupRequired => "setup_required", - Self::Personal => "personal", - Self::Farmer => "farmer", - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LoggedOutStartupPhase { - #[default] - ContinuePrompt, - IdentityChoice, - GenerateKeyStarting, - SignerEntry, -} - -impl LoggedOutStartupPhase { - pub const fn storage_key(self) -> &'static str { - match self { - Self::ContinuePrompt => "continue_prompt", - Self::IdentityChoice => "identity_choice", - Self::GenerateKeyStarting => "generate_key_starting", - Self::SignerEntry => "signer_entry", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StartupSignerSourceKind { - BunkerUri, - DiscoveryUrl, -} - -impl StartupSignerSourceKind { - pub const fn storage_key(self) -> &'static str { - match self { - Self::BunkerUri => "bunker_uri", - Self::DiscoveryUrl => "discovery_url", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ParseStartupSignerSourceError { - EmptyInput, - UnsupportedClientUri, - UnsupportedSource, - MissingDiscoveryUri, -} - -impl fmt::Display for ParseStartupSignerSourceError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyInput => formatter.write_str("signer source input must not be empty"), - Self::UnsupportedClientUri => formatter.write_str( - "client nostrconnect URIs are not accepted by the app signer entry flow", - ), - Self::UnsupportedSource => { - formatter.write_str("signer source input must be a bunker URI or discovery URL") - } - Self::MissingDiscoveryUri => { - formatter.write_str("discovery URL must include a non-empty uri query parameter") - } - } - } -} - -impl Error for ParseStartupSignerSourceError {} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", content = "value", rename_all = "snake_case")] -pub enum StartupSignerSource { - BunkerUri(String), - DiscoveryUrl(String), -} - -impl StartupSignerSource { - pub const fn kind(&self) -> StartupSignerSourceKind { - match self { - Self::BunkerUri(_) => StartupSignerSourceKind::BunkerUri, - Self::DiscoveryUrl(_) => StartupSignerSourceKind::DiscoveryUrl, - } - } - - pub fn value(&self) -> &str { - match self { - Self::BunkerUri(value) | Self::DiscoveryUrl(value) => value, - } - } -} - -impl FromStr for StartupSignerSource { - type Err = ParseStartupSignerSourceError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(ParseStartupSignerSourceError::EmptyInput); - } - - if trimmed.starts_with("nostrconnect://") { - return Err(ParseStartupSignerSourceError::UnsupportedClientUri); - } - - if trimmed.starts_with("bunker://") { - return Ok(Self::BunkerUri(trimmed.to_owned())); - } - - let url = - Url::parse(trimmed).map_err(|_| ParseStartupSignerSourceError::UnsupportedSource)?; - let has_discovery_uri = url - .query_pairs() - .any(|(key, value)| key == "uri" && !value.trim().is_empty()); - - if !has_discovery_uri { - return Err(ParseStartupSignerSourceError::MissingDiscoveryUri); - } - - Ok(Self::DiscoveryUrl(trimmed.to_owned())) - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct StartupSignerEntryProjection { - pub source_input: String, -} - -impl StartupSignerEntryProjection { - pub fn new(source_input: impl Into<String>) -> Self { - Self { - source_input: source_input.into(), - } - } - - pub fn parsed_source(&self) -> Result<StartupSignerSource, ParseStartupSignerSourceError> { - self.source_input.parse() - } - - pub fn set_source_input(&mut self, source_input: impl Into<String>) { - self.source_input = source_input.into(); - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct LoggedOutStartupProjection { - pub phase: LoggedOutStartupPhase, - pub signer_entry: StartupSignerEntryProjection, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", content = "account_id", rename_all = "snake_case")] -pub enum BuyerContext { - #[default] - Guest, - Account(String), -} - -impl BuyerContext { - pub const fn guest() -> Self { - Self::Guest - } - - pub fn account(account_id: impl Into<String>) -> Self { - Self::Account(account_id.into()) - } - - pub fn storage_key(&self) -> String { - match self { - Self::Guest => "guest".to_owned(), - Self::Account(account_id) => format!("account:{account_id}"), - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PersonalEntryState { - Blocked, - #[default] - Guest, - SignedIn, -} - -impl PersonalEntryState { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Blocked => "blocked", - Self::Guest => "guest", - Self::SignedIn => "signed_in", - } - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct PersonalEntryProjection { - pub state: PersonalEntryState, - pub selected_account: Option<SelectedAccountProjection>, - pub can_enter_farmer_workspace: bool, -} - -impl PersonalEntryProjection { - pub fn blocked(selected_account: Option<SelectedAccountProjection>) -> Self { - let can_enter_farmer_workspace = selected_account - .as_ref() - .is_some_and(|account| account.farmer_activation.is_active()); - - Self { - state: PersonalEntryState::Blocked, - selected_account, - can_enter_farmer_workspace, - } - } - - pub const fn guest() -> Self { - Self { - state: PersonalEntryState::Guest, - selected_account: None, - can_enter_farmer_workspace: false, - } - } - - pub fn signed_in(selected_account: SelectedAccountProjection) -> Self { - let can_enter_farmer_workspace = selected_account.farmer_activation.is_active(); - - Self { - state: PersonalEntryState::SignedIn, - selected_account: Some(selected_account), - can_enter_farmer_workspace, - } - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct AppIdentityProjection { - pub readiness: IdentityReadiness, - pub roster: Vec<AccountSummary>, - pub selected_account: Option<SelectedAccountProjection>, -} - -impl AppIdentityProjection { - pub fn missing() -> Self { - Self::with_readiness(IdentityReadiness::MissingAccount, Vec::new(), None) - } - - pub fn missing_with_roster(roster: Vec<AccountSummary>) -> Self { - Self::with_readiness(IdentityReadiness::MissingAccount, roster, None) - } - - pub fn blocked(reason: IdentityBlockedReason) -> Self { - Self::with_readiness(IdentityReadiness::Blocked(reason), Vec::new(), None) - } - - pub fn blocked_with_selection( - reason: IdentityBlockedReason, - roster: Vec<AccountSummary>, - selected_account: Option<SelectedAccountProjection>, - ) -> Self { - Self::with_readiness(IdentityReadiness::Blocked(reason), roster, selected_account) - } - - pub fn ready(roster: Vec<AccountSummary>, selected_account: SelectedAccountProjection) -> Self { - Self::with_readiness(IdentityReadiness::Ready, roster, Some(selected_account)) - } - - pub fn with_readiness( - readiness: IdentityReadiness, - mut roster: Vec<AccountSummary>, - selected_account: Option<SelectedAccountProjection>, - ) -> Self { - if let Some(selected_account) = selected_account.as_ref() - && !roster - .iter() - .any(|account| account.account_id == selected_account.account.account_id) - { - roster.insert(0, selected_account.account.clone()); - } - - Self { - readiness, - roster, - selected_account, - } - } - - pub fn startup_gate(&self) -> AppStartupGate { - match self.readiness { - IdentityReadiness::MissingAccount => AppStartupGate::SetupRequired, - IdentityReadiness::Blocked(_) => AppStartupGate::Blocked, - IdentityReadiness::Ready => self - .selected_account - .as_ref() - .map(|account| { - if account.farmer_activation.is_active() - && account.active_surface() == ActiveSurface::Farmer - { - AppStartupGate::Farmer - } else { - AppStartupGate::Personal - } - }) - .unwrap_or(AppStartupGate::SetupRequired), - } - } - - pub fn settings_account(&self) -> SettingsAccountProjection { - self.into() - } - - pub fn personal_entry(&self) -> PersonalEntryProjection { - match self.readiness { - IdentityReadiness::MissingAccount => PersonalEntryProjection::guest(), - IdentityReadiness::Blocked(_) => { - PersonalEntryProjection::blocked(self.selected_account.clone()) - } - IdentityReadiness::Ready => self - .selected_account - .clone() - .map(PersonalEntryProjection::signed_in) - .unwrap_or_else(PersonalEntryProjection::guest), - } - } - - pub fn buyer_context(&self) -> BuyerContext { - self.selected_account - .as_ref() - .map(|account| BuyerContext::account(account.account.account_id.clone())) - .unwrap_or_default() - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct SettingsAccountProjection { - pub readiness: IdentityReadiness, - pub roster: Vec<AccountSummary>, - pub selected_account: Option<SelectedAccountProjection>, -} - -impl From<&AppIdentityProjection> for SettingsAccountProjection { - fn from(value: &AppIdentityProjection) -> Self { - Self { - readiness: value.readiness, - roster: value.roster.clone(), - selected_account: value.selected_account.clone(), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FarmReadiness { - Incomplete, - Ready, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct FarmProfileRecord { - pub farm_id: FarmId, - pub display_name: String, - pub timezone: String, - pub currency_code: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct FarmOperatingRulesRecord { - pub farm_id: FarmId, - pub promise_lead_hours: u16, - pub substitution_policy: String, - pub missed_pickup_policy: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PickupLocationRecord { - pub pickup_location_id: PickupLocationId, - pub farm_id: FarmId, - pub label: String, - pub address_line: String, - pub directions: Option<String>, - pub is_default: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct FulfillmentWindowRecord { - pub fulfillment_window_id: FulfillmentWindowId, - pub farm_id: FarmId, - pub pickup_location_id: PickupLocationId, - pub label: String, - pub starts_at: String, - pub ends_at: String, - pub order_cutoff_at: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct BlackoutPeriodRecord { - pub blackout_period_id: BlackoutPeriodId, - pub farm_id: FarmId, - pub label: String, - pub starts_at: String, - pub ends_at: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FarmReadinessBlocker { - MissingProfileBasics, - MissingPickupLocation, - MissingFulfillmentWindow, - MissingOperatingRules, -} - -impl FarmReadinessBlocker { - pub const fn storage_key(self) -> &'static str { - match self { - Self::MissingProfileBasics => "missing_profile_basics", - Self::MissingPickupLocation => "missing_pickup_location", - Self::MissingFulfillmentWindow => "missing_fulfillment_window", - Self::MissingOperatingRules => "missing_operating_rules", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FarmTimingConflictKind { - FulfillmentWindowEndsBeforeStart, - FulfillmentWindowCutoffAfterStart, - BlackoutPeriodEndsBeforeStart, - BlackoutOverlapsFulfillmentWindow, -} - -impl FarmTimingConflictKind { - pub const fn storage_key(self) -> &'static str { - match self { - Self::FulfillmentWindowEndsBeforeStart => "fulfillment_window_ends_before_start", - Self::FulfillmentWindowCutoffAfterStart => "fulfillment_window_cutoff_after_start", - Self::BlackoutPeriodEndsBeforeStart => "blackout_period_ends_before_start", - Self::BlackoutOverlapsFulfillmentWindow => "blackout_overlaps_fulfillment_window", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct FarmTimingConflict { - pub kind: FarmTimingConflictKind, - pub fulfillment_window_id: Option<FulfillmentWindowId>, - pub blackout_period_id: Option<BlackoutPeriodId>, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct FarmRulesReadiness { - pub blockers: Vec<FarmReadinessBlocker>, - pub timing_conflicts: Vec<FarmTimingConflict>, -} - -impl FarmRulesReadiness { - pub fn ready() -> Self { - Self { - blockers: Vec::new(), - timing_conflicts: Vec::new(), - } - } - - pub fn missing_v1_basics() -> Self { - Self { - blockers: vec![ - FarmReadinessBlocker::MissingProfileBasics, - FarmReadinessBlocker::MissingPickupLocation, - FarmReadinessBlocker::MissingFulfillmentWindow, - FarmReadinessBlocker::MissingOperatingRules, - ], - timing_conflicts: Vec::new(), - } - } - - pub fn is_ready(&self) -> bool { - self.blockers.is_empty() && self.timing_conflicts.is_empty() - } -} - -impl Default for FarmRulesReadiness { - fn default() -> Self { - Self::ready() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct FarmRulesProjection { - pub farm_profile: Option<FarmProfileRecord>, - pub pickup_locations: Vec<PickupLocationRecord>, - pub operating_rules: Option<FarmOperatingRulesRecord>, - pub fulfillment_windows: Vec<FulfillmentWindowRecord>, - pub blackout_periods: Vec<BlackoutPeriodRecord>, - pub readiness: FarmRulesReadiness, -} - -impl Default for FarmRulesProjection { - fn default() -> Self { - Self { - farm_profile: None, - pickup_locations: Vec::new(), - operating_rules: None, - fulfillment_windows: Vec::new(), - blackout_periods: Vec::new(), - readiness: FarmRulesReadiness::missing_v1_basics(), - } - } -} - -impl FarmRulesProjection { - pub fn is_ready(&self) -> bool { - self.readiness.is_ready() - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProductStatus { - #[default] - Draft, - Published, - Paused, - Archived, -} - -impl ProductStatus { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Draft => "draft", - Self::Published => "published", - Self::Paused => "paused", - Self::Archived => "archived", - } - } - - pub const fn is_live(self) -> bool { - matches!(self, Self::Published) - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProductsFilter { - #[default] - All, - Live, - Drafts, - NeedAttention, - Paused, - Archived, -} - -impl ProductsFilter { - pub const fn storage_key(self) -> &'static str { - match self { - Self::All => "all", - Self::Live => "live", - Self::Drafts => "drafts", - Self::NeedAttention => "need_attention", - Self::Paused => "paused", - Self::Archived => "archived", - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProductsSort { - #[default] - Updated, - Name, - Availability, - Stock, - Price, -} - -impl ProductsSort { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Updated => "updated", - Self::Name => "name", - Self::Availability => "availability", - Self::Stock => "stock", - Self::Price => "price", - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProductAttentionState { - #[default] - Healthy, - LowStock, - SoldOut, - MissingAvailability, - NoFutureAvailability, - MissingDetails, -} - -impl ProductAttentionState { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Healthy => "healthy", - Self::LowStock => "low_stock", - Self::SoldOut => "sold_out", - Self::MissingAvailability => "missing_availability", - Self::NoFutureAvailability => "no_future_availability", - Self::MissingDetails => "missing_details", - } - } - - pub const fn requires_attention(self) -> bool { - !matches!(self, Self::Healthy) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProductAvailabilityState { - Scheduled, - Open, - MissingWindow, - NoFutureWindow, -} - -impl ProductAvailabilityState { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Scheduled => "scheduled", - Self::Open => "open", - Self::MissingWindow => "missing_window", - Self::NoFutureWindow => "no_future_window", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ProductAvailabilitySummary { - pub state: ProductAvailabilityState, - pub label: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProductStockState { - Unset, - InStock, - LowStock, - SoldOut, -} - -impl ProductStockState { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Unset => "unset", - Self::InStock => "in_stock", - Self::LowStock => "low_stock", - Self::SoldOut => "sold_out", - } - } - - pub const fn requires_attention(self) -> bool { - matches!(self, Self::LowStock | Self::SoldOut) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ProductStockSummary { - pub quantity: Option<u32>, - pub unit_label: Option<String>, - pub state: ProductStockState, -} - -impl ProductStockSummary { - pub const fn requires_attention(&self) -> bool { - self.state.requires_attention() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ProductPricePresentation { - pub amount_minor_units: u32, - pub currency_code: String, - pub unit_label: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct ProductsListSummary { - pub total_products: u32, - pub live_products: u32, - pub draft_products: u32, - pub need_attention_products: u32, -} - -impl ProductsListSummary { - pub const fn has_products(&self) -> bool { - self.total_products > 0 - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ProductsListRow { - pub product_id: ProductId, - pub farm_id: FarmId, - pub title: String, - pub subtitle: Option<String>, - pub status: ProductStatus, - pub attention_state: ProductAttentionState, - pub availability: ProductAvailabilitySummary, - pub stock: ProductStockSummary, - pub price: Option<ProductPricePresentation>, - pub updated_at: String, -} - -impl ProductsListRow { - pub const fn requires_attention(&self) -> bool { - self.attention_state.requires_attention() || self.stock.requires_attention() - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct ProductsListProjection { - pub summary: ProductsListSummary, - pub rows: Vec<ProductsListRow>, -} - -impl ProductsListProjection { - pub fn is_empty(&self) -> bool { - self.rows.is_empty() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProductPublishBlocker { - AddProductName, - ChooseCategory, - ChooseUnit, - SetPrice, - SetStock, - AttachAvailability, - CompleteFarmProfile, - AddPickupLocation, - AddOperatingRules, - AddFulfillmentWindow, - ResolveAvailabilityConflicts, -} - -impl ProductPublishBlocker { - pub const fn storage_key(self) -> &'static str { - match self { - Self::AddProductName => "add_product_name", - Self::ChooseCategory => "choose_category", - Self::ChooseUnit => "choose_unit", - Self::SetPrice => "set_price", - Self::SetStock => "set_stock", - 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", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ProductEditorDraft { - pub title: String, - pub subtitle: String, - pub category: String, - pub unit_label: String, - pub price_minor_units: Option<u32>, - pub price_currency: String, - pub stock_quantity: Option<u32>, - pub availability_window_id: Option<FulfillmentWindowId>, - pub status: ProductStatus, -} - -impl Default for ProductEditorDraft { - fn default() -> Self { - Self { - title: String::new(), - subtitle: String::new(), - category: String::new(), - unit_label: String::new(), - price_minor_units: None, - price_currency: "USD".to_owned(), - stock_quantity: None, - availability_window_id: None, - status: ProductStatus::Draft, - } - } -} - -impl ProductEditorDraft { - pub fn publish_blockers(&self) -> Vec<ProductPublishBlocker> { - let mut blockers = Vec::new(); - - if self.title.trim().is_empty() { - blockers.push(ProductPublishBlocker::AddProductName); - } - - if self.category.trim().is_empty() { - blockers.push(ProductPublishBlocker::ChooseCategory); - } - - if self.unit_label.trim().is_empty() { - blockers.push(ProductPublishBlocker::ChooseUnit); - } - - if self.price_minor_units.is_none_or(|value| value == 0) { - blockers.push(ProductPublishBlocker::SetPrice); - } - - if self.stock_quantity.is_none() { - blockers.push(ProductPublishBlocker::SetStock); - } - - if self.availability_window_id.is_none() { - blockers.push(ProductPublishBlocker::AttachAvailability); - } - - blockers - } - - pub fn is_publish_ready(&self) -> bool { - self.publish_blockers().is_empty() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerListingRow { - pub product_id: ProductId, - pub farm_id: FarmId, - pub farm_display_name: String, - pub listing_relays: Vec<String>, - pub title: String, - pub subtitle: Option<String>, - pub price: ProductPricePresentation, - pub availability: ProductAvailabilitySummary, - pub stock: ProductStockSummary, - pub fulfillment_methods: BTreeSet<FarmOrderMethod>, - pub next_fulfillment_window_label: Option<String>, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerListingsProjection { - pub rows: Vec<BuyerListingRow>, -} - -impl BuyerListingsProjection { - pub fn is_empty(&self) -> bool { - self.rows.is_empty() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerProductDetailProjection { - pub listing: BuyerListingRow, - pub detail_text: Option<String>, - pub selected_quantity: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCartLineProjection { - pub product_id: ProductId, - pub farm_id: FarmId, - pub farm_display_name: String, - pub title: String, - pub quantity: u32, - pub unit_price: ProductPricePresentation, - pub line_total_minor_units: u32, - pub fulfillment_summary: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCartReplaceConfirmationProjection { - pub current_farm_display_name: String, - pub incoming_farm_display_name: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCartProjection { - pub farm_id: Option<FarmId>, - pub farm_display_name: Option<String>, - pub lines: Vec<BuyerCartLineProjection>, - pub subtotal_minor_units: Option<u32>, - pub currency_code: Option<String>, - pub replace_confirmation: Option<BuyerCartReplaceConfirmationProjection>, -} - -impl BuyerCartProjection { - pub fn is_empty(&self) -> bool { - self.lines.is_empty() - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCheckoutDraft { - pub name: String, - pub email: String, - pub phone: String, - pub order_note: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCheckoutSummaryProjection { - pub farm_display_name: Option<String>, - pub fulfillment_summary: Option<String>, - pub line_count: u32, - pub subtotal_minor_units: Option<u32>, - pub currency_code: Option<String>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BuyerCheckoutDisabledReason { - EmptyCart, - MissingFulfillment, - MissingName, - MissingEmail, - AccountRequired, -} - -impl BuyerCheckoutDisabledReason { - pub const fn storage_key(self) -> &'static str { - match self { - Self::EmptyCart => "empty_cart", - Self::MissingFulfillment => "missing_fulfillment", - Self::MissingName => "missing_name", - Self::MissingEmail => "missing_email", - Self::AccountRequired => "account_required", - } - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerCheckoutProjection { - pub draft: BuyerCheckoutDraft, - pub summary: BuyerCheckoutSummaryProjection, - pub can_place_order: bool, - pub place_order_disabled_reason: Option<BuyerCheckoutDisabledReason>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OrderStatus { - NeedsAction, - Scheduled, - Packed, - Completed, - Declined, - Refunded, -} - -impl OrderStatus { - pub const fn storage_key(self) -> &'static str { - match self { - Self::NeedsAction => "needs_action", - Self::Scheduled => "scheduled", - Self::Packed => "packed", - Self::Completed => "completed", - Self::Declined => "declined", - Self::Refunded => "refunded", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BuyerOrderStatus { - Placed, - Scheduled, - Ready, - Completed, - Declined, - Refunded, -} - -impl BuyerOrderStatus { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Placed => "placed", - Self::Scheduled => "scheduled", - Self::Ready => "ready", - Self::Completed => "completed", - Self::Declined => "declined", - Self::Refunded => "refunded", - } - } -} - -impl From<OrderStatus> for BuyerOrderStatus { - fn from(value: OrderStatus) -> Self { - match value { - OrderStatus::NeedsAction => Self::Placed, - OrderStatus::Scheduled => Self::Scheduled, - OrderStatus::Packed => Self::Ready, - OrderStatus::Completed => Self::Completed, - OrderStatus::Declined => Self::Declined, - OrderStatus::Refunded => Self::Refunded, - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OrdersFilter { - All, - #[default] - NeedsAction, - Scheduled, - Packed, - Completed, - Refunded, -} - -impl OrdersFilter { - pub const fn storage_key(self) -> &'static str { - match self { - Self::All => "all", - Self::NeedsAction => "needs_action", - Self::Scheduled => "scheduled", - Self::Packed => "packed", - Self::Completed => "completed", - Self::Refunded => "refunded", - } - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct OrdersScreenQueryState { - pub filter: OrdersFilter, - pub fulfillment_window_id: Option<FulfillmentWindowId>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OrderPrimaryAction { - Review, - MarkPacked, - MarkCompleted, -} - -impl OrderPrimaryAction { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Review => "review", - Self::MarkPacked => "mark_packed", - Self::MarkCompleted => "mark_completed", - } - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct OrdersListSummary { - pub total_orders: u32, - pub needs_action_orders: u32, - pub scheduled_orders: u32, - pub packed_orders: u32, -} - -impl OrdersListSummary { - pub const fn has_orders(&self) -> bool { - self.total_orders > 0 - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct OrdersListRow { - pub order_id: OrderId, - pub farm_id: FarmId, - pub fulfillment_window_id: Option<FulfillmentWindowId>, - pub order_number: String, - pub customer_display_name: String, - pub fulfillment_window_label: Option<String>, - pub pickup_location_label: Option<String>, - pub status: OrderStatus, - pub primary_action: Option<OrderPrimaryAction>, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct OrdersListProjection { - pub summary: OrdersListSummary, - pub rows: Vec<OrdersListRow>, -} - -impl OrdersListProjection { - pub fn is_empty(&self) -> bool { - self.rows.is_empty() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct OrderDetailItemRow { - pub title: String, - pub quantity_display: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct OrderDetailProjection { - pub order_id: OrderId, - pub farm_id: FarmId, - pub order_number: String, - pub customer_display_name: String, - pub status: OrderStatus, - pub fulfillment_window_id: Option<FulfillmentWindowId>, - pub fulfillment_window_label: Option<String>, - pub pickup_location_label: Option<String>, - pub items: Vec<OrderDetailItemRow>, - pub primary_action: Option<OrderPrimaryAction>, - pub recoveries: Vec<OrderRecoveryProjection>, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerOrdersListRow { - pub order_id: OrderId, - pub farm_id: FarmId, - pub order_number: String, - pub farm_display_name: String, - pub fulfillment_summary: String, - pub status: BuyerOrderStatus, - pub repeat_demand: Option<RepeatDemandHandoffProjection>, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerOrdersProjection { - pub rows: Vec<BuyerOrdersListRow>, -} - -impl BuyerOrdersProjection { - pub fn is_empty(&self) -> bool { - self.rows.is_empty() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct BuyerOrderDetailProjection { - pub order_id: OrderId, - pub farm_id: FarmId, - pub order_number: String, - pub farm_display_name: String, - pub fulfillment_summary: String, - pub status: BuyerOrderStatus, - pub items: Vec<OrderDetailItemRow>, - pub order_note: Option<String>, - pub repeat_demand: Option<RepeatDemandHandoffProjection>, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayScreenQueryState { - pub fulfillment_window_id: Option<FulfillmentWindowId>, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayProductTotalRow { - pub title: String, - pub quantity_display: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayPackListRow { - pub title: String, - pub quantity_display: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayRosterRow { - pub order_id: OrderId, - pub order_number: String, - pub customer_display_name: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayProjection { - pub fulfillment_window: Option<FulfillmentWindowSummary>, - pub reminders: ReminderFeedProjection, - pub totals_by_product: Vec<PackDayProductTotalRow>, - pub pack_list: Vec<PackDayPackListRow>, - pub pickup_roster: Vec<PackDayRosterRow>, -} - -impl PackDayProjection { - pub fn is_empty(&self) -> bool { - self.totals_by_product.is_empty() - && self.pack_list.is_empty() - && self.pickup_roster.is_empty() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PackDayExportArtifactKind { - PackSheet, - PickupRoster, - CustomerLabels, -} - -impl PackDayExportArtifactKind { - pub const fn all_v1() -> [Self; 3] { - [Self::PackSheet, Self::PickupRoster, Self::CustomerLabels] - } - - pub const fn storage_key(self) -> &'static str { - match self { - Self::PackSheet => "pack_sheet", - Self::PickupRoster => "pickup_roster", - Self::CustomerLabels => "customer_labels", - } - } - - pub const fn file_name(self) -> &'static str { - match self { - Self::PackSheet => "pack_sheet.txt", - Self::PickupRoster => "pickup_roster.txt", - Self::CustomerLabels => "customer_labels.txt", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PackDayPrintKind { - PrintPackSheet, - PrintPickupRoster, - PrintCustomerLabels, -} - -impl PackDayPrintKind { - pub const fn all_v1() -> [Self; 3] { - [ - Self::PrintPackSheet, - Self::PrintPickupRoster, - Self::PrintCustomerLabels, - ] - } - - pub const fn storage_key(self) -> &'static str { - match self { - Self::PrintPackSheet => "print_pack_sheet", - Self::PrintPickupRoster => "print_pickup_roster", - Self::PrintCustomerLabels => "print_customer_labels", - } - } - - pub const fn artifact_kind(self) -> PackDayExportArtifactKind { - match self { - Self::PrintPackSheet => PackDayExportArtifactKind::PackSheet, - Self::PrintPickupRoster => PackDayExportArtifactKind::PickupRoster, - Self::PrintCustomerLabels => PackDayExportArtifactKind::CustomerLabels, - } - } - - pub const fn label_stock(self) -> Option<PackDayPrintLabelStock> { - match self { - Self::PrintPackSheet | Self::PrintPickupRoster => None, - Self::PrintCustomerLabels => Some(PackDayPrintLabelStock::Avery5160Letter30Up), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PackDayPrintLabelStock { - Avery5160Letter30Up, -} - -impl PackDayPrintLabelStock { - pub const fn all_v1() -> [Self; 1] { - [Self::Avery5160Letter30Up] - } - - pub const fn storage_key(self) -> &'static str { - match self { - Self::Avery5160Letter30Up => "avery_5160_letter_30_up", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PackDayPrintFailureKind { - CustomerLabelsAvery5160Overflow, -} - -impl PackDayPrintFailureKind { - pub const fn storage_key(self) -> &'static str { - match self { - Self::CustomerLabelsAvery5160Overflow => "customer_labels_avery_5160_overflow", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayBatchPrintArtifact { - pub print_kind: PackDayPrintKind, - pub artifact_kind: PackDayExportArtifactKind, - pub label_stock: Option<PackDayPrintLabelStock>, -} - -impl PackDayBatchPrintArtifact { - pub const fn all_v1() -> [Self; 3] { - [ - Self::from_print_kind(PackDayPrintKind::PrintPackSheet), - Self::from_print_kind(PackDayPrintKind::PrintPickupRoster), - Self::from_print_kind(PackDayPrintKind::PrintCustomerLabels), - ] - } - - pub const fn from_print_kind(print_kind: PackDayPrintKind) -> Self { - Self { - print_kind, - artifact_kind: print_kind.artifact_kind(), - label_stock: print_kind.label_stock(), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PackDayBatchPrintFailureKind { - Preflight, - QueueLaunch, - QueueExit, - CustomerLabelsAvery5160Overflow, -} - -impl PackDayBatchPrintFailureKind { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Preflight => "preflight", - Self::QueueLaunch => "queue_launch", - Self::QueueExit => "queue_exit", - Self::CustomerLabelsAvery5160Overflow => "customer_labels_avery_5160_overflow", - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PackDayBatchPrintStatus { - #[default] - Idle, - Running, - Succeeded, - Failed, -} - -impl PackDayBatchPrintStatus { - 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, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PackDayPrintStatus { - #[default] - Idle, - Running, - Succeeded, - Failed, -} - -impl PackDayPrintStatus { - 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 PackDayHostHandoffKind { - RevealBundle, - OpenPackSheet, - OpenPickupRoster, - OpenCustomerLabels, -} - -impl PackDayHostHandoffKind { - pub const fn all_v1() -> [Self; 4] { - [ - Self::RevealBundle, - Self::OpenPackSheet, - Self::OpenPickupRoster, - Self::OpenCustomerLabels, - ] - } - - pub const fn storage_key(self) -> &'static str { - match self { - Self::RevealBundle => "reveal_bundle", - Self::OpenPackSheet => "open_pack_sheet", - Self::OpenPickupRoster => "open_pickup_roster", - Self::OpenCustomerLabels => "open_customer_labels", - } - } - - pub const fn artifact_kind(self) -> Option<PackDayExportArtifactKind> { - match self { - Self::RevealBundle => None, - Self::OpenPackSheet => Some(PackDayExportArtifactKind::PackSheet), - Self::OpenPickupRoster => Some(PackDayExportArtifactKind::PickupRoster), - Self::OpenCustomerLabels => Some(PackDayExportArtifactKind::CustomerLabels), - } - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PackDayExportStatus { - #[default] - Idle, - Running, - Succeeded, - Failed, -} - -impl PackDayExportStatus { - 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, 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 { - NeedsAction, - Scheduled, - Packed, -} - -impl PackDayOutputOrderState { - pub const fn all_v1() -> [Self; 3] { - [Self::NeedsAction, Self::Scheduled, Self::Packed] - } - - pub const fn storage_key(self) -> &'static str { - match self { - Self::NeedsAction => "needs_action", - Self::Scheduled => "scheduled", - Self::Packed => "packed", - } - } - - pub const fn from_order_status(status: OrderStatus) -> Option<Self> { - match status { - OrderStatus::NeedsAction => Some(Self::NeedsAction), - OrderStatus::Scheduled => Some(Self::Scheduled), - OrderStatus::Packed => Some(Self::Packed), - OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => None, - } - } -} - -impl From<PackDayOutputOrderState> for OrderStatus { - fn from(value: PackDayOutputOrderState) -> Self { - match value { - PackDayOutputOrderState::NeedsAction => Self::NeedsAction, - PackDayOutputOrderState::Scheduled => Self::Scheduled, - PackDayOutputOrderState::Packed => Self::Packed, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayOutputQuantity { - pub value: u32, - pub unit_label: String, -} - -impl PackDayOutputQuantity { - pub fn new(value: u32, unit_label: impl Into<String>) -> Self { - Self { - value, - unit_label: unit_label.into(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayOutputWindow { - pub fulfillment_window_id: FulfillmentWindowId, - pub farm_id: FarmId, - pub farm_display_name: String, - pub pickup_location_label: Option<String>, - pub starts_at: String, - pub ends_at: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayOutputProductTotal { - pub title: String, - pub quantity: PackDayOutputQuantity, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayOutputPackListEntry { - pub order_id: OrderId, - pub order_number: String, - pub customer_display_name: String, - pub order_state: PackDayOutputOrderState, - pub title: String, - pub quantity: PackDayOutputQuantity, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayOutputCustomerOrder { - pub order_id: OrderId, - pub order_number: String, - pub customer_display_name: String, - pub order_state: PackDayOutputOrderState, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayOutputSource { - pub fulfillment_window: PackDayOutputWindow, - pub totals_by_product: Vec<PackDayOutputProductTotal>, - pub pack_list: Vec<PackDayOutputPackListEntry>, - pub pickup_roster: Vec<PackDayOutputCustomerOrder>, -} - -impl PackDayOutputSource { - pub fn is_empty(&self) -> bool { - self.totals_by_product.is_empty() - && self.pack_list.is_empty() - && self.pickup_roster.is_empty() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayExportArtifact { - pub kind: PackDayExportArtifactKind, - pub relative_path: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct PackDayExportBundle { - pub fulfillment_window_id: FulfillmentWindowId, - pub export_instance_id: PackDayExportInstanceId, - pub generated_at_utc: String, - pub bundle_directory: String, - pub artifacts: Vec<PackDayExportArtifact>, -} - -impl PackDayExportBundle { - pub fn artifact_count(&self) -> usize { - self.artifacts.len() - } - - pub fn includes_artifact(&self, kind: PackDayExportArtifactKind) -> bool { - self.artifacts.iter().any(|artifact| artifact.kind == kind) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct FarmSummary { - pub farm_id: FarmId, - pub display_name: String, - 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, - pub farm_id: FarmId, - pub starts_at: String, - pub ends_at: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct TodaySummary { - pub farm_id: FarmId, - pub orders_needing_action: u32, - pub low_stock_products: u32, - pub draft_products: u32, - pub reminders_due_soon: u32, - pub recovery_actions_open: u32, -} - -impl TodaySummary { - pub const fn has_attention_items(&self) -> bool { - self.orders_needing_action > 0 - || self.low_stock_products > 0 - || self.draft_products > 0 - || self.reminders_due_soon > 0 - || self.recovery_actions_open > 0 - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReminderSurface { - Today, - Orders, - PackDay, -} - -impl ReminderSurface { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Today => "today", - Self::Orders => "orders", - Self::PackDay => "pack_day", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReminderKind { - FulfillmentWindow, - OrderAction, - MissedPickupRecovery, - RefundRecovery, - SyncImpact, -} - -impl ReminderKind { - pub const fn storage_key(self) -> &'static str { - match self { - Self::FulfillmentWindow => "fulfillment_window", - Self::OrderAction => "order_action", - Self::MissedPickupRecovery => "missed_pickup_recovery", - Self::RefundRecovery => "refund_recovery", - Self::SyncImpact => "sync_impact", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReminderUrgency { - Upcoming, - DueSoon, - Overdue, - Blocking, -} - -impl ReminderUrgency { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Upcoming => "upcoming", - Self::DueSoon => "due_soon", - Self::Overdue => "overdue", - Self::Blocking => "blocking", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReminderDeliveryState { - Scheduled, - Presented, - Acknowledged, - Resolved, -} - -impl ReminderDeliveryState { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Scheduled => "scheduled", - Self::Presented => "presented", - Self::Acknowledged => "acknowledged", - Self::Resolved => "resolved", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ReminderDeadlineProjection { - pub reminder_id: ReminderId, - pub farm_id: FarmId, - pub order_id: Option<OrderId>, - pub fulfillment_window_id: Option<FulfillmentWindowId>, - pub kind: ReminderKind, - pub surface: ReminderSurface, - pub urgency: ReminderUrgency, - pub title: String, - pub detail: String, - pub deadline_at: String, - pub action_label: Option<String>, - pub delivery_state: ReminderDeliveryState, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct ReminderFeedProjection { - pub items: Vec<ReminderDeadlineProjection>, -} - -impl ReminderFeedProjection { - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - pub fn due_soon_count(&self) -> usize { - self.items - .iter() - .filter(|item| { - matches!( - item.urgency, - ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking - ) - }) - .count() - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ReminderLogEntryProjection { - pub reminder_id: ReminderId, - pub kind: ReminderKind, - pub title: String, - pub recorded_at: String, - pub delivery_state: ReminderDeliveryState, - pub detail: Option<String>, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct ReminderLogProjection { - pub entries: Vec<ReminderLogEntryProjection>, -} - -impl ReminderLogProjection { - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RecoveryKind { - MissedPickup, - RefundFollowUp, -} - -impl RecoveryKind { - pub const fn storage_key(self) -> &'static str { - match self { - Self::MissedPickup => "missed_pickup", - Self::RefundFollowUp => "refund_follow_up", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RecoveryState { - Open, - InReview, - Resolved, -} - -impl RecoveryState { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Open => "open", - Self::InReview => "in_review", - Self::Resolved => "resolved", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct OrderRecoveryProjection { - pub recovery_record_id: RecoveryRecordId, - pub order_id: OrderId, - pub kind: RecoveryKind, - pub state: RecoveryState, - pub summary: String, - pub note: Option<String>, - pub last_updated_at: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct RecoveryQueueProjection { - pub items: Vec<OrderRecoveryProjection>, -} - -impl RecoveryQueueProjection { - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RepeatDemandEligibility { - Eligible, - Partial, - Unavailable, -} - -impl RepeatDemandEligibility { - pub const fn storage_key(self) -> &'static str { - match self { - Self::Eligible => "eligible", - Self::Partial => "partial", - Self::Unavailable => "unavailable", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct RepeatDemandHandoffProjection { - pub order_id: OrderId, - pub farm_id: FarmId, - pub eligibility: RepeatDemandEligibility, - pub available_item_count: u32, - pub unavailable_item_count: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub enum AppActivityKind { - HomeOpened, - SettingsOpened { - section: SettingsSection, - }, - SettingsSectionSelected { - section: SettingsSection, - }, - SettingsPreferenceUpdated { - preference: SettingsPreference, - enabled: bool, - }, -} - -impl AppActivityKind { - pub const fn storage_key(&self) -> &'static str { - match self { - Self::HomeOpened => "home_opened", - Self::SettingsOpened { .. } => "settings_opened", - Self::SettingsSectionSelected { .. } => "settings_section_selected", - Self::SettingsPreferenceUpdated { .. } => "settings_preference_updated", - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct AppActivityEvent { - pub activity_event_id: ActivityEventId, - pub recorded_at: String, - pub kind: AppActivityKind, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct AppActivityContext { - pub recent_events: Vec<AppActivityEvent>, -} - -impl AppActivityContext { - pub fn from_recent_events(recent_events: Vec<AppActivityEvent>) -> Self { - Self { recent_events } - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ProductListRow { - pub product_id: ProductId, - pub farm_id: FarmId, - pub title: String, - pub status: ProductStatus, - pub stock_count: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct OrderListRow { - pub order_id: OrderId, - pub farm_id: FarmId, - pub fulfillment_window_id: Option<FulfillmentWindowId>, - pub order_number: String, - pub customer_display_name: String, - pub status: OrderStatus, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TodaySetupTaskKind { - CompleteFarmProfile, - AddPickupLocation, - AddOperatingRules, - AddFulfillmentWindow, - ResolveAvailabilityConflicts, - PublishProduct, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct TodaySetupTask { - pub kind: TodaySetupTaskKind, - pub is_complete: bool, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct TodayAgendaProjection { - pub farm: Option<FarmSummary>, - pub summary: Option<TodaySummary>, - pub reminders: ReminderFeedProjection, - pub orders_needing_action: Vec<OrderListRow>, - pub low_stock_products: Vec<ProductListRow>, - pub draft_products: Vec<ProductListRow>, - pub next_fulfillment_window: Option<FulfillmentWindowSummary>, - pub setup_checklist: Vec<TodaySetupTask>, -} - -impl TodayAgendaProjection { - pub fn has_attention_items(&self) -> bool { - self.summary - .as_ref() - .is_some_and(TodaySummary::has_attention_items) - || !self.reminders.is_empty() - || !self.orders_needing_action.is_empty() - || !self.low_stock_products.is_empty() - || !self.draft_products.is_empty() - } - - pub fn needs_setup(&self) -> bool { - self.setup_checklist.iter().any(|item| !item.is_complete) - } -} - -#[cfg(test)] -mod tests { - use super::{ - AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, - ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, - AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BuyerCartLineProjection, - BuyerCartProjection, BuyerCheckoutDisabledReason, BuyerCheckoutDraft, - BuyerCheckoutProjection, BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, - BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, - BuyerOrdersProjection, FarmId, FarmOrderMethod, FarmReadinessBlocker, FarmRulesProjection, - FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, - FarmSetupReadiness, FarmSetupSection, FarmTimingConflict, FarmTimingConflictKind, - FarmerActivationProjection, FarmerSection, FulfillmentWindowId, IdentityBlockedReason, - IdentityReadiness, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow, - OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, OrderRecoveryProjection, - OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, - OrdersScreenQueryState, PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, - PackDayBatchPrintStatus, PackDayExportArtifact, PackDayExportArtifactKind, - PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, - PackDayHostHandoffStatus, PackDayOutputCustomerOrder, PackDayOutputOrderState, - PackDayOutputPackListEntry, PackDayOutputProductTotal, PackDayOutputQuantity, - PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, PackDayPrintFailureKind, - PackDayPrintKind, PackDayPrintLabelStock, PackDayPrintStatus, 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, - }; - use std::{collections::BTreeSet, str::FromStr}; - use uuid::Uuid; - - #[test] - fn shell_section_storage_keys_are_unique_and_round_trip() { - let sections = [ - ShellSection::Home, - ShellSection::Personal(PersonalSection::Browse), - ShellSection::Personal(PersonalSection::Search), - ShellSection::Personal(PersonalSection::Cart), - ShellSection::Personal(PersonalSection::Orders), - ShellSection::Farmer(FarmerSection::Today), - ShellSection::Farmer(FarmerSection::Products), - ShellSection::Farmer(FarmerSection::Orders), - ShellSection::Farmer(FarmerSection::PackDay), - ShellSection::Farmer(FarmerSection::Farm), - ShellSection::Settings(SettingsSection::Account), - ShellSection::Settings(SettingsSection::Farm), - ShellSection::Settings(SettingsSection::Settings), - ShellSection::Settings(SettingsSection::About), - ]; - let keys = sections - .into_iter() - .map(ShellSection::storage_key) - .collect::<BTreeSet<_>>(); - - assert_eq!(keys.len(), sections.len()); - - for section in sections { - let parsed = - ShellSection::from_str(section.storage_key()).expect("section should parse"); - assert_eq!(parsed, section); - } - } - - #[test] - fn shell_section_surface_is_explicit_for_surface_routes_only() { - assert_eq!(ShellSection::Home.surface(), None); - assert_eq!( - ShellSection::Personal(PersonalSection::Browse).surface(), - Some(ActiveSurface::Personal) - ); - assert_eq!( - ShellSection::Farmer(FarmerSection::Today).surface(), - Some(ActiveSurface::Farmer) - ); - assert_eq!( - ShellSection::Settings(SettingsSection::Settings).surface(), - None - ); - } - - #[test] - fn shell_section_default_for_surface_preserves_current_farmer_entry() { - assert_eq!( - ShellSection::default_for_surface(ActiveSurface::Personal), - ShellSection::Personal(PersonalSection::Browse) - ); - assert_eq!( - ShellSection::default_for_surface(ActiveSurface::Farmer), - ShellSection::Farmer(FarmerSection::Today) - ); - } - - #[test] - fn selected_surface_defaults_to_personal() { - assert_eq!( - SelectedSurfaceProjection::default().active_surface, - ActiveSurface::Personal - ); - } - - #[test] - fn selected_account_without_farmer_activation_falls_back_to_personal_surface() { - let projection = SelectedAccountProjection::new( - AccountSummary { - account_id: "acct_01".to_owned(), - npub: "npub1example".to_owned(), - label: Some("North field".to_owned()), - custody: AccountCustody::LocalManaged, - }, - SelectedSurfaceProjection::new(ActiveSurface::Farmer), - FarmerActivationProjection::inactive(), - ); - - assert_eq!(projection.active_surface(), ActiveSurface::Personal); - assert!(!projection.farmer_activation.is_active()); - } - - #[test] - fn account_surface_activation_projection_normalizes_to_personal_without_farm_binding() { - let projection = AccountSurfaceActivationProjection::new( - "acct_04", - SelectedSurfaceProjection::new(ActiveSurface::Farmer), - FarmerActivationProjection::inactive(), - ); - - assert_eq!(projection.account_id, "acct_04"); - assert_eq!(projection.active_surface(), ActiveSurface::Personal); - assert!(!projection.farmer_activation.is_active()); - } - - #[test] - fn selected_account_projection_round_trips_through_surface_activation_state() { - let selected_account = SelectedAccountProjection::new( - AccountSummary { - account_id: "acct_roundtrip".to_owned(), - npub: "npub1roundtrip".to_owned(), - label: Some("Roundtrip".to_owned()), - custody: AccountCustody::LocalManaged, - }, - SelectedSurfaceProjection::new(ActiveSurface::Farmer), - FarmerActivationProjection::active(FarmId::new()), - ); - let activation = AccountSurfaceActivationProjection::from(&selected_account); - let restored = SelectedAccountProjection::from_surface_activation( - selected_account.account.clone(), - activation, - ); - - assert_eq!(restored, selected_account); - } - - #[test] - fn startup_gate_tracks_setup_personal_farmer_and_blocked_states() { - let farmer_identity = AppIdentityProjection::ready( - Vec::new(), - SelectedAccountProjection::new( - AccountSummary { - account_id: "acct_02".to_owned(), - npub: "npub1farmer".to_owned(), - label: None, - custody: AccountCustody::LocalManaged, - }, - SelectedSurfaceProjection::new(ActiveSurface::Farmer), - FarmerActivationProjection::active(FarmId::new()), - ), - ); - let personal_identity = AppIdentityProjection::ready( - Vec::new(), - SelectedAccountProjection::new( - AccountSummary { - account_id: "acct_03".to_owned(), - npub: "npub1personal".to_owned(), - label: None, - custody: AccountCustody::LocalManaged, - }, - SelectedSurfaceProjection::new(ActiveSurface::Personal), - FarmerActivationProjection::inactive(), - ), - ); - - assert_eq!( - AppIdentityProjection::missing().startup_gate(), - AppStartupGate::SetupRequired - ); - assert_eq!(personal_identity.startup_gate(), AppStartupGate::Personal); - assert_eq!(farmer_identity.startup_gate(), AppStartupGate::Farmer); - assert_eq!( - AppIdentityProjection::blocked(IdentityBlockedReason::HostVaultUnavailable) - .startup_gate(), - AppStartupGate::Blocked - ); - } - - #[test] - fn ready_identity_keeps_selected_account_visible_in_roster() { - let selected_account = SelectedAccountProjection::new( - AccountSummary { - account_id: "acct_selected".to_owned(), - npub: "npub1selected".to_owned(), - label: None, - custody: AccountCustody::RemoteSigner, - }, - SelectedSurfaceProjection::new(ActiveSurface::Personal), - FarmerActivationProjection::inactive(), - ); - let projection = AppIdentityProjection::ready(Vec::new(), selected_account.clone()); - - assert_eq!(projection.readiness.storage_key(), "ready"); - assert_eq!(projection.roster.len(), 1); - assert_eq!(projection.roster[0], selected_account.account); - assert_eq!(projection.selected_account, Some(selected_account)); - } - - #[test] - fn blocked_identity_keeps_selected_account_visible_in_roster() { - let selected_account = SelectedAccountProjection::new( - AccountSummary { - account_id: "acct_blocked".to_owned(), - npub: "npub1blocked".to_owned(), - label: Some("Blocked account".to_owned()), - custody: AccountCustody::LocalManaged, - }, - SelectedSurfaceProjection::new(ActiveSurface::Personal), - FarmerActivationProjection::inactive(), - ); - let projection = AppIdentityProjection::blocked_with_selection( - IdentityBlockedReason::HostVaultUnavailable, - Vec::new(), - Some(selected_account.clone()), - ); - - assert_eq!( - projection.readiness, - IdentityReadiness::Blocked(IdentityBlockedReason::HostVaultUnavailable) - ); - assert_eq!(projection.roster, vec![selected_account.account.clone()]); - assert_eq!(projection.selected_account, Some(selected_account)); - assert_eq!(projection.startup_gate(), AppStartupGate::Blocked); - } - - #[test] - fn missing_identity_can_keep_roster_visible_without_selected_account() { - let roster = vec![AccountSummary { - account_id: "acct_waiting".to_owned(), - npub: "npub1waiting".to_owned(), - label: Some("Waiting".to_owned()), - custody: AccountCustody::LocalManaged, - }]; - let projection = AppIdentityProjection::missing_with_roster(roster.clone()); - - assert_eq!(projection.readiness, IdentityReadiness::MissingAccount); - assert_eq!(projection.roster, roster); - assert!(projection.selected_account.is_none()); - assert_eq!(projection.startup_gate(), AppStartupGate::SetupRequired); - } - - #[test] - fn personal_entry_projection_is_derived_from_identity_truth() { - let guest_identity = AppIdentityProjection::missing(); - let selected_account = SelectedAccountProjection::new( - AccountSummary { - account_id: "acct_farmer".to_owned(), - npub: "npub1farmer".to_owned(), - label: Some("Field stand".to_owned()), - custody: AccountCustody::LocalManaged, - }, - SelectedSurfaceProjection::new(ActiveSurface::Farmer), - FarmerActivationProjection::active(FarmId::new()), - ); - let signed_in_identity = AppIdentityProjection::ready(Vec::new(), selected_account.clone()); - let blocked_identity = AppIdentityProjection::blocked_with_selection( - IdentityBlockedReason::HostVaultUnavailable, - Vec::new(), - Some(selected_account.clone()), - ); - - assert_eq!( - guest_identity.personal_entry(), - PersonalEntryProjection::guest() - ); - assert_eq!( - guest_identity.personal_entry().state.storage_key(), - PersonalEntryState::Guest.storage_key() - ); - assert_eq!( - signed_in_identity.personal_entry(), - PersonalEntryProjection::signed_in(selected_account.clone()) - ); - assert!( - signed_in_identity - .personal_entry() - .can_enter_farmer_workspace - ); - assert_eq!( - blocked_identity.personal_entry(), - PersonalEntryProjection::blocked(Some(selected_account)) - ); - } - - #[test] - fn buyer_context_defaults_to_guest_and_tracks_selected_account() { - let selected_account = SelectedAccountProjection::new( - AccountSummary { - account_id: "acct_buyer".to_owned(), - npub: "npub1buyer".to_owned(), - label: Some("Buyer".to_owned()), - custody: AccountCustody::LocalManaged, - }, - SelectedSurfaceProjection::new(ActiveSurface::Personal), - FarmerActivationProjection::inactive(), - ); - let ready_identity = AppIdentityProjection::ready(Vec::new(), selected_account); - - assert_eq!(BuyerContext::guest().storage_key(), "guest"); - assert_eq!( - BuyerContext::account("acct_buyer").storage_key(), - "account:acct_buyer" - ); - assert_eq!( - AppIdentityProjection::missing().buyer_context(), - BuyerContext::Guest - ); - assert_eq!( - ready_identity.buyer_context(), - BuyerContext::account("acct_buyer") - ); - } - - #[test] - fn logged_out_startup_defaults_to_continue_prompt_with_empty_signer_entry() { - assert_eq!( - LoggedOutStartupProjection::default(), - LoggedOutStartupProjection { - phase: LoggedOutStartupPhase::ContinuePrompt, - signer_entry: StartupSignerEntryProjection::default(), - } - ); - } - - #[test] - fn logged_out_startup_phase_and_signer_source_kind_storage_keys_are_stable() { - assert_eq!( - LoggedOutStartupPhase::ContinuePrompt.storage_key(), - "continue_prompt" - ); - assert_eq!( - LoggedOutStartupPhase::IdentityChoice.storage_key(), - "identity_choice" - ); - assert_eq!( - LoggedOutStartupPhase::GenerateKeyStarting.storage_key(), - "generate_key_starting" - ); - assert_eq!( - LoggedOutStartupPhase::SignerEntry.storage_key(), - "signer_entry" - ); - assert_eq!( - StartupSignerSourceKind::BunkerUri.storage_key(), - "bunker_uri" - ); - assert_eq!( - StartupSignerSourceKind::DiscoveryUrl.storage_key(), - "discovery_url" - ); - } - - #[test] - fn startup_signer_source_parses_direct_bunker_uri_and_discovery_url() { - let bunker_uri = - "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example&secret=test-secret"; - let discovery_url = - format!("https://signer.radroots.example/connect?uri={bunker_uri}&label=field"); - - let bunker_source = bunker_uri - .parse::<StartupSignerSource>() - .expect("bunker uri should parse"); - let discovery_source = discovery_url - .parse::<StartupSignerSource>() - .expect("discovery url should parse"); - - assert_eq!( - bunker_source, - StartupSignerSource::BunkerUri(bunker_uri.to_owned()) - ); - assert_eq!(bunker_source.kind(), StartupSignerSourceKind::BunkerUri); - assert_eq!(bunker_source.value(), bunker_uri); - assert_eq!( - discovery_source, - StartupSignerSource::DiscoveryUrl(discovery_url.clone()) - ); - assert_eq!( - discovery_source.kind(), - StartupSignerSourceKind::DiscoveryUrl - ); - assert_eq!(discovery_source.value(), discovery_url); - } - - #[test] - fn startup_signer_source_rejects_empty_client_uri_and_missing_discovery_uri() { - assert_eq!( - "".parse::<StartupSignerSource>(), - Err(ParseStartupSignerSourceError::EmptyInput) - ); - assert_eq!( - "nostrconnect://npub1client?relay=wss%3A%2F%2Frelay.radroots.example&secret=test" - .parse::<StartupSignerSource>(), - Err(ParseStartupSignerSourceError::UnsupportedClientUri) - ); - assert_eq!( - "https://signer.radroots.example/connect".parse::<StartupSignerSource>(), - Err(ParseStartupSignerSourceError::MissingDiscoveryUri) - ); - assert_eq!( - "not a signer source".parse::<StartupSignerSource>(), - Err(ParseStartupSignerSourceError::UnsupportedSource) - ); - } - - #[test] - fn signer_entry_projection_exposes_the_typed_source_contract() { - let mut projection = StartupSignerEntryProjection::new( - " bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example ", - ); - - assert_eq!( - projection.parsed_source(), - Ok(StartupSignerSource::BunkerUri( - "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example".to_owned() - )) - ); - - projection.set_source_input("https://signer.radroots.example/connect?uri=bunker://npub1"); - assert_eq!( - projection.parsed_source(), - Ok(StartupSignerSource::DiscoveryUrl( - "https://signer.radroots.example/connect?uri=bunker://npub1".to_owned() - )) - ); - } - - #[test] - fn typed_ids_round_trip_through_strings() { - let uuid = Uuid::parse_str("018f4d61-19b0-7cc4-9d4e-6d0df7c0aa11") - .expect("test uuid should parse"); - let farm_id = FarmId::from(uuid); - let parsed = FarmId::from_str(&farm_id.to_string()).expect("farm id should parse"); - - assert_eq!(parsed, farm_id); - assert_eq!(parsed.as_uuid(), uuid); - } - - #[test] - fn product_status_filter_and_sort_storage_keys_are_stable() { - assert_eq!(ProductStatus::Draft.storage_key(), "draft"); - assert_eq!(ProductStatus::Published.storage_key(), "published"); - assert_eq!(ProductStatus::Paused.storage_key(), "paused"); - assert_eq!(ProductStatus::Archived.storage_key(), "archived"); - assert!(ProductStatus::Published.is_live()); - assert!(!ProductStatus::Draft.is_live()); - - assert_eq!(ProductsFilter::default(), ProductsFilter::All); - assert_eq!(ProductsFilter::All.storage_key(), "all"); - assert_eq!(ProductsFilter::Live.storage_key(), "live"); - assert_eq!(ProductsFilter::Drafts.storage_key(), "drafts"); - assert_eq!( - ProductsFilter::NeedAttention.storage_key(), - "need_attention" - ); - assert_eq!(ProductsFilter::Paused.storage_key(), "paused"); - assert_eq!(ProductsFilter::Archived.storage_key(), "archived"); - - assert_eq!(ProductsSort::default(), ProductsSort::Updated); - assert_eq!(ProductsSort::Updated.storage_key(), "updated"); - assert_eq!(ProductsSort::Name.storage_key(), "name"); - assert_eq!(ProductsSort::Availability.storage_key(), "availability"); - assert_eq!(ProductsSort::Stock.storage_key(), "stock"); - assert_eq!(ProductsSort::Price.storage_key(), "price"); - } - - #[test] - fn buyer_checkout_disabled_reason_storage_keys_are_stable() { - assert_eq!( - BuyerCheckoutDisabledReason::EmptyCart.storage_key(), - "empty_cart" - ); - assert_eq!( - BuyerCheckoutDisabledReason::MissingFulfillment.storage_key(), - "missing_fulfillment" - ); - assert_eq!( - BuyerCheckoutDisabledReason::MissingName.storage_key(), - "missing_name" - ); - assert_eq!( - BuyerCheckoutDisabledReason::MissingEmail.storage_key(), - "missing_email" - ); - assert_eq!( - BuyerCheckoutDisabledReason::AccountRequired.storage_key(), - "account_required" - ); - } - - #[test] - fn product_attention_stock_and_projection_states_are_explicit() { - let row = ProductsListRow { - product_id: super::ProductId::new(), - farm_id: FarmId::new(), - title: "Pea shoots".to_owned(), - subtitle: Some("Tray-grown".to_owned()), - status: ProductStatus::Draft, - attention_state: ProductAttentionState::MissingAvailability, - availability: ProductAvailabilitySummary { - state: ProductAvailabilityState::MissingWindow, - label: "Missing window".to_owned(), - }, - stock: ProductStockSummary { - quantity: None, - unit_label: None, - state: ProductStockState::Unset, - }, - price: Some(ProductPricePresentation { - amount_minor_units: 300, - currency_code: "USD".to_owned(), - unit_label: "bag".to_owned(), - }), - updated_at: "2026-04-18T10:00:00Z".to_owned(), - }; - let summary = ProductsListSummary { - total_products: 1, - live_products: 0, - draft_products: 1, - need_attention_products: 1, - }; - let projection = ProductsListProjection { - summary: summary.clone(), - rows: vec![row.clone()], - }; - - assert_eq!(ProductAttentionState::LowStock.storage_key(), "low_stock"); - assert!(ProductAttentionState::LowStock.requires_attention()); - assert!(!ProductAttentionState::Healthy.requires_attention()); - assert_eq!( - ProductAvailabilityState::MissingWindow.storage_key(), - "missing_window" - ); - assert_eq!(ProductStockState::SoldOut.storage_key(), "sold_out"); - assert!(ProductStockState::SoldOut.requires_attention()); - assert!(!ProductStockState::InStock.requires_attention()); - assert!(row.requires_attention()); - assert!(summary.has_products()); - assert!(!projection.is_empty()); - assert_eq!(projection.rows[0].availability.label, "Missing window"); - } - - #[test] - fn product_editor_publish_blockers_are_explicit_and_minimal() { - let empty_draft = ProductEditorDraft::default(); - let ready_draft = ProductEditorDraft { - title: "Heirloom tomatoes".to_owned(), - subtitle: "Brandywine".to_owned(), - category: "vegetables".to_owned(), - unit_label: "lb".to_owned(), - price_minor_units: Some(450), - price_currency: "USD".to_owned(), - stock_quantity: Some(12), - availability_window_id: Some(super::FulfillmentWindowId::new()), - status: ProductStatus::Draft, - }; - - assert_eq!( - empty_draft.publish_blockers(), - vec![ - ProductPublishBlocker::AddProductName, - ProductPublishBlocker::ChooseCategory, - ProductPublishBlocker::ChooseUnit, - ProductPublishBlocker::SetPrice, - ProductPublishBlocker::SetStock, - ProductPublishBlocker::AttachAvailability, - ] - ); - assert_eq!( - ProductPublishBlocker::AttachAvailability.storage_key(), - "attach_availability" - ); - assert_eq!(empty_draft.price_currency, "USD"); - assert!(!empty_draft.is_publish_ready()); - assert!(ready_draft.is_publish_ready()); - assert!(ready_draft.publish_blockers().is_empty()); - } - - #[test] - fn order_status_filter_and_primary_action_storage_keys_are_stable() { - assert_eq!(OrderStatus::NeedsAction.storage_key(), "needs_action"); - assert_eq!(OrderStatus::Scheduled.storage_key(), "scheduled"); - assert_eq!(OrderStatus::Packed.storage_key(), "packed"); - assert_eq!(OrderStatus::Completed.storage_key(), "completed"); - assert_eq!(OrderStatus::Declined.storage_key(), "declined"); - assert_eq!(OrderStatus::Refunded.storage_key(), "refunded"); - assert_eq!(BuyerOrderStatus::Placed.storage_key(), "placed"); - assert_eq!(BuyerOrderStatus::Scheduled.storage_key(), "scheduled"); - assert_eq!(BuyerOrderStatus::Ready.storage_key(), "ready"); - assert_eq!(BuyerOrderStatus::Completed.storage_key(), "completed"); - assert_eq!(BuyerOrderStatus::Declined.storage_key(), "declined"); - assert_eq!(BuyerOrderStatus::Refunded.storage_key(), "refunded"); - assert_eq!( - BuyerOrderStatus::from(OrderStatus::NeedsAction), - BuyerOrderStatus::Placed - ); - assert_eq!( - BuyerOrderStatus::from(OrderStatus::Packed), - BuyerOrderStatus::Ready - ); - assert_eq!( - BuyerOrderStatus::from(OrderStatus::Declined), - BuyerOrderStatus::Declined - ); - - assert_eq!(OrdersFilter::default(), OrdersFilter::NeedsAction); - assert_eq!(OrdersFilter::All.storage_key(), "all"); - assert_eq!(OrdersFilter::NeedsAction.storage_key(), "needs_action"); - assert_eq!(OrdersFilter::Scheduled.storage_key(), "scheduled"); - assert_eq!(OrdersFilter::Packed.storage_key(), "packed"); - assert_eq!(OrdersFilter::Completed.storage_key(), "completed"); - assert_eq!(OrdersFilter::Refunded.storage_key(), "refunded"); - - assert_eq!(OrderPrimaryAction::Review.storage_key(), "review"); - assert_eq!(OrderPrimaryAction::MarkPacked.storage_key(), "mark_packed"); - assert_eq!( - OrderPrimaryAction::MarkCompleted.storage_key(), - "mark_completed" - ); - } - - #[test] - fn orders_and_pack_day_query_state_defaults_are_frozen() { - assert_eq!( - OrdersScreenQueryState::default(), - OrdersScreenQueryState { - filter: OrdersFilter::NeedsAction, - fulfillment_window_id: None, - } - ); - assert_eq!( - PackDayScreenQueryState::default(), - PackDayScreenQueryState { - fulfillment_window_id: None, - } - ); - } - - #[test] - fn pack_day_export_print_and_host_handoff_contracts_are_frozen_for_v1() { - assert_eq!( - PackDayExportArtifactKind::all_v1(), - [ - PackDayExportArtifactKind::PackSheet, - PackDayExportArtifactKind::PickupRoster, - PackDayExportArtifactKind::CustomerLabels, - ] - ); - assert_eq!( - PackDayExportArtifactKind::PackSheet.storage_key(), - "pack_sheet" - ); - assert_eq!( - PackDayExportArtifactKind::PackSheet.file_name(), - "pack_sheet.txt" - ); - assert_eq!( - PackDayExportArtifactKind::PickupRoster.file_name(), - "pickup_roster.txt" - ); - assert_eq!( - PackDayExportArtifactKind::CustomerLabels.file_name(), - "customer_labels.txt" - ); - assert_eq!(PackDayExportStatus::default(), PackDayExportStatus::Idle); - assert_eq!(PackDayExportStatus::Running.storage_key(), "running"); - assert_eq!(PackDayExportStatus::Succeeded.storage_key(), "succeeded"); - assert_eq!(PackDayExportStatus::Failed.storage_key(), "failed"); - assert_eq!( - PackDayPrintKind::all_v1(), - [ - PackDayPrintKind::PrintPackSheet, - PackDayPrintKind::PrintPickupRoster, - PackDayPrintKind::PrintCustomerLabels, - ] - ); - assert_eq!( - PackDayPrintKind::PrintPackSheet.storage_key(), - "print_pack_sheet" - ); - assert_eq!( - PackDayPrintKind::PrintPickupRoster.storage_key(), - "print_pickup_roster" - ); - assert_eq!( - PackDayPrintKind::PrintCustomerLabels.storage_key(), - "print_customer_labels" - ); - assert_eq!( - PackDayPrintKind::PrintPackSheet.artifact_kind(), - PackDayExportArtifactKind::PackSheet - ); - assert_eq!( - PackDayPrintKind::PrintPickupRoster.artifact_kind(), - PackDayExportArtifactKind::PickupRoster - ); - assert_eq!( - PackDayPrintKind::PrintCustomerLabels.artifact_kind(), - PackDayExportArtifactKind::CustomerLabels - ); - assert_eq!(PackDayPrintKind::PrintPackSheet.label_stock(), None); - assert_eq!(PackDayPrintKind::PrintPickupRoster.label_stock(), None); - assert_eq!( - PackDayPrintKind::PrintCustomerLabels.label_stock(), - Some(PackDayPrintLabelStock::Avery5160Letter30Up) - ); - assert_eq!( - PackDayPrintLabelStock::all_v1(), - [PackDayPrintLabelStock::Avery5160Letter30Up] - ); - assert_eq!( - PackDayPrintLabelStock::Avery5160Letter30Up.storage_key(), - "avery_5160_letter_30_up" - ); - assert_eq!( - PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow.storage_key(), - "customer_labels_avery_5160_overflow" - ); - assert_eq!( - PackDayBatchPrintArtifact::all_v1(), - [ - PackDayBatchPrintArtifact { - print_kind: PackDayPrintKind::PrintPackSheet, - artifact_kind: PackDayExportArtifactKind::PackSheet, - label_stock: None, - }, - PackDayBatchPrintArtifact { - print_kind: PackDayPrintKind::PrintPickupRoster, - artifact_kind: PackDayExportArtifactKind::PickupRoster, - label_stock: None, - }, - PackDayBatchPrintArtifact { - print_kind: PackDayPrintKind::PrintCustomerLabels, - artifact_kind: PackDayExportArtifactKind::CustomerLabels, - label_stock: Some(PackDayPrintLabelStock::Avery5160Letter30Up), - }, - ] - ); - assert_eq!( - PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintCustomerLabels), - PackDayBatchPrintArtifact { - print_kind: PackDayPrintKind::PrintCustomerLabels, - artifact_kind: PackDayExportArtifactKind::CustomerLabels, - label_stock: Some(PackDayPrintLabelStock::Avery5160Letter30Up), - } - ); - assert_eq!( - PackDayBatchPrintFailureKind::Preflight.storage_key(), - "preflight" - ); - assert_eq!( - PackDayBatchPrintFailureKind::QueueLaunch.storage_key(), - "queue_launch" - ); - assert_eq!( - PackDayBatchPrintFailureKind::QueueExit.storage_key(), - "queue_exit" - ); - assert_eq!( - PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow.storage_key(), - "customer_labels_avery_5160_overflow" - ); - assert_eq!( - PackDayBatchPrintStatus::default(), - PackDayBatchPrintStatus::Idle - ); - assert_eq!(PackDayBatchPrintStatus::Running.storage_key(), "running"); - assert_eq!( - PackDayBatchPrintStatus::Succeeded.storage_key(), - "succeeded" - ); - assert_eq!(PackDayBatchPrintStatus::Failed.storage_key(), "failed"); - assert_eq!(PackDayPrintStatus::default(), PackDayPrintStatus::Idle); - assert_eq!(PackDayPrintStatus::Running.storage_key(), "running"); - assert_eq!(PackDayPrintStatus::Succeeded.storage_key(), "succeeded"); - assert_eq!(PackDayPrintStatus::Failed.storage_key(), "failed"); - assert_eq!( - PackDayHostHandoffKind::all_v1(), - [ - PackDayHostHandoffKind::RevealBundle, - PackDayHostHandoffKind::OpenPackSheet, - PackDayHostHandoffKind::OpenPickupRoster, - PackDayHostHandoffKind::OpenCustomerLabels, - ] - ); - assert_eq!( - PackDayHostHandoffKind::RevealBundle.storage_key(), - "reveal_bundle" - ); - assert_eq!( - PackDayHostHandoffKind::OpenPackSheet.storage_key(), - "open_pack_sheet" - ); - assert_eq!( - PackDayHostHandoffKind::OpenPickupRoster.storage_key(), - "open_pickup_roster" - ); - assert_eq!( - PackDayHostHandoffKind::OpenCustomerLabels.storage_key(), - "open_customer_labels" - ); - assert_eq!(PackDayHostHandoffKind::RevealBundle.artifact_kind(), None); - assert_eq!( - PackDayHostHandoffKind::OpenPackSheet.artifact_kind(), - Some(PackDayExportArtifactKind::PackSheet) - ); - assert_eq!( - PackDayHostHandoffKind::OpenPickupRoster.artifact_kind(), - Some(PackDayExportArtifactKind::PickupRoster) - ); - assert_eq!( - PackDayHostHandoffKind::OpenCustomerLabels.artifact_kind(), - Some(PackDayExportArtifactKind::CustomerLabels) - ); - 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] - fn pack_day_output_order_state_freezes_the_v1_status_subset() { - assert_eq!( - PackDayOutputOrderState::all_v1(), - [ - PackDayOutputOrderState::NeedsAction, - PackDayOutputOrderState::Scheduled, - PackDayOutputOrderState::Packed, - ] - ); - assert_eq!( - PackDayOutputOrderState::from_order_status(OrderStatus::NeedsAction), - Some(PackDayOutputOrderState::NeedsAction) - ); - assert_eq!( - PackDayOutputOrderState::from_order_status(OrderStatus::Scheduled), - Some(PackDayOutputOrderState::Scheduled) - ); - assert_eq!( - PackDayOutputOrderState::from_order_status(OrderStatus::Packed), - Some(PackDayOutputOrderState::Packed) - ); - assert_eq!( - PackDayOutputOrderState::from_order_status(OrderStatus::Completed), - None - ); - assert_eq!( - PackDayOutputOrderState::from_order_status(OrderStatus::Declined), - None - ); - assert_eq!( - PackDayOutputOrderState::from_order_status(OrderStatus::Refunded), - None - ); - assert_eq!( - OrderStatus::from(PackDayOutputOrderState::Packed), - OrderStatus::Packed - ); - } - - #[test] - fn pack_day_output_source_keeps_export_truth_out_of_ui_display_strings() { - let farm_id = FarmId::new(); - let fulfillment_window_id = FulfillmentWindowId::new(); - let order_id = OrderId::new(); - let screen_row = PackDayPackListRow { - title: "Salad mix".to_owned(), - quantity_display: "Casey: 2 bags".to_owned(), - }; - let source = PackDayOutputSource { - fulfillment_window: PackDayOutputWindow { - fulfillment_window_id, - farm_id, - farm_display_name: "Willow farm".to_owned(), - pickup_location_label: Some("North barn".to_owned()), - starts_at: "2026-04-23T16:00:00Z".to_owned(), - ends_at: "2026-04-23T19:00:00Z".to_owned(), - }, - totals_by_product: vec![PackDayOutputProductTotal { - title: "Salad mix".to_owned(), - quantity: PackDayOutputQuantity::new(2, "bags"), - }], - pack_list: vec![PackDayOutputPackListEntry { - order_id, - order_number: "R-1001".to_owned(), - customer_display_name: "Casey".to_owned(), - order_state: PackDayOutputOrderState::Scheduled, - title: "Salad mix".to_owned(), - quantity: PackDayOutputQuantity::new(2, "bags"), - }], - pickup_roster: vec![PackDayOutputCustomerOrder { - order_id, - order_number: "R-1001".to_owned(), - customer_display_name: "Casey".to_owned(), - order_state: PackDayOutputOrderState::Scheduled, - }], - }; - - assert_eq!(screen_row.quantity_display, "Casey: 2 bags"); - assert!(!source.is_empty()); - assert_eq!(source.pack_list[0].customer_display_name, "Casey"); - assert_eq!(source.pack_list[0].quantity.value, 2); - assert_eq!(source.pack_list[0].quantity.unit_label, "bags"); - assert_eq!( - source.pickup_roster[0].order_state.storage_key(), - "scheduled" - ); - } - - #[test] - fn pack_day_export_bundle_tracks_output_directory_and_artifacts() { - let fulfillment_window_id = FulfillmentWindowId::new(); - let bundle = PackDayExportBundle { - fulfillment_window_id, - export_instance_id: PackDayExportInstanceId::new(), - generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), - bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), - artifacts: vec![ - PackDayExportArtifact { - kind: PackDayExportArtifactKind::PackSheet, - relative_path: "pack_sheet.txt".to_owned(), - }, - PackDayExportArtifact { - kind: PackDayExportArtifactKind::PickupRoster, - relative_path: "pickup_roster.txt".to_owned(), - }, - ], - }; - - assert_eq!(bundle.fulfillment_window_id, fulfillment_window_id); - assert_eq!(bundle.artifact_count(), 2); - assert!(bundle.includes_artifact(PackDayExportArtifactKind::PackSheet)); - assert!(bundle.includes_artifact(PackDayExportArtifactKind::PickupRoster)); - assert!(!bundle.includes_artifact(PackDayExportArtifactKind::CustomerLabels)); - } - - #[test] - fn orders_and_pack_day_projections_hold_truthful_execution_data() { - let fulfillment_window_id = super::FulfillmentWindowId::new(); - let farm_id = FarmId::new(); - let order_id = super::OrderId::new(); - let orders_list = OrdersListProjection { - summary: OrdersListSummary { - total_orders: 3, - needs_action_orders: 1, - scheduled_orders: 1, - packed_orders: 1, - }, - rows: vec![OrdersListRow { - order_id, - farm_id, - fulfillment_window_id: Some(fulfillment_window_id), - order_number: "R-1001".to_owned(), - customer_display_name: "Casey".to_owned(), - fulfillment_window_label: Some("Wednesday pickup".to_owned()), - pickup_location_label: Some("North barn".to_owned()), - status: OrderStatus::Scheduled, - primary_action: Some(OrderPrimaryAction::MarkPacked), - }], - }; - let order_detail = OrderDetailProjection { - order_id, - farm_id, - order_number: "R-1001".to_owned(), - customer_display_name: "Casey".to_owned(), - status: OrderStatus::Scheduled, - fulfillment_window_id: Some(fulfillment_window_id), - fulfillment_window_label: Some("Wednesday pickup".to_owned()), - pickup_location_label: Some("North barn".to_owned()), - items: vec![OrderDetailItemRow { - title: "Salad mix".to_owned(), - quantity_display: "2 bags".to_owned(), - }], - primary_action: Some(OrderPrimaryAction::MarkPacked), - recoveries: Vec::new(), - }; - let pack_day = PackDayProjection { - fulfillment_window: Some(super::FulfillmentWindowSummary { - fulfillment_window_id, - farm_id, - starts_at: "2026-04-23T16:00:00Z".to_owned(), - ends_at: "2026-04-23T19:00:00Z".to_owned(), - }), - totals_by_product: vec![PackDayProductTotalRow { - title: "Salad mix".to_owned(), - quantity_display: "8 bags".to_owned(), - }], - pack_list: vec![PackDayPackListRow { - title: "Salad mix".to_owned(), - quantity_display: "Casey: 2 bags".to_owned(), - }], - pickup_roster: vec![PackDayRosterRow { - order_id, - order_number: "R-1001".to_owned(), - customer_display_name: "Casey".to_owned(), - }], - reminders: ReminderFeedProjection::default(), - }; - - assert!(orders_list.summary.has_orders()); - assert!(!orders_list.is_empty()); - assert_eq!( - orders_list.rows[0].primary_action, - Some(OrderPrimaryAction::MarkPacked) - ); - assert_eq!(order_detail.items[0].quantity_display, "2 bags"); - assert!(!pack_day.is_empty()); - assert_eq!(pack_day.pickup_roster[0].order_number, "R-1001"); - } - - #[test] - fn buyer_marketplace_projections_hold_guest_capable_contract_data() { - let farm_id = FarmId::new(); - let product_id = super::ProductId::new(); - let order_id = super::OrderId::new(); - let listing = BuyerListingRow { - product_id, - farm_id, - farm_display_name: "Cedar Grove Farm".to_owned(), - listing_relays: vec!["wss://relay.example".to_owned()], - title: "Spring salad mix".to_owned(), - subtitle: Some("Tender leaves".to_owned()), - price: ProductPricePresentation { - amount_minor_units: 650, - currency_code: "USD".to_owned(), - unit_label: "bag".to_owned(), - }, - availability: ProductAvailabilitySummary { - state: ProductAvailabilityState::Scheduled, - label: "Thursday pickup".to_owned(), - }, - stock: ProductStockSummary { - quantity: Some(8), - unit_label: Some("bag".to_owned()), - state: ProductStockState::InStock, - }, - fulfillment_methods: BTreeSet::from([FarmOrderMethod::Pickup]), - next_fulfillment_window_label: Some("Thursday pickup".to_owned()), - }; - let listings = BuyerListingsProjection { - rows: vec![listing.clone()], - }; - let cart = BuyerCartProjection { - farm_id: Some(farm_id), - farm_display_name: Some("Cedar Grove Farm".to_owned()), - lines: vec![BuyerCartLineProjection { - product_id, - farm_id, - farm_display_name: "Cedar Grove Farm".to_owned(), - title: "Spring salad mix".to_owned(), - quantity: 2, - unit_price: ProductPricePresentation { - amount_minor_units: 650, - currency_code: "USD".to_owned(), - unit_label: "bag".to_owned(), - }, - line_total_minor_units: 1300, - fulfillment_summary: "Thursday pickup".to_owned(), - }], - subtotal_minor_units: Some(1300), - currency_code: Some("USD".to_owned()), - replace_confirmation: None, - }; - let checkout = BuyerCheckoutProjection { - draft: BuyerCheckoutDraft { - name: "Casey Buyer".to_owned(), - email: "casey@example.com".to_owned(), - phone: String::new(), - order_note: "Leave by the cooler".to_owned(), - }, - summary: BuyerCheckoutSummaryProjection { - farm_display_name: Some("Cedar Grove Farm".to_owned()), - fulfillment_summary: Some("Thursday pickup".to_owned()), - line_count: 1, - subtotal_minor_units: Some(1300), - currency_code: Some("USD".to_owned()), - }, - can_place_order: true, - place_order_disabled_reason: None, - }; - let orders = BuyerOrdersProjection { - rows: vec![BuyerOrdersListRow { - order_id, - farm_id, - order_number: "R-2001".to_owned(), - farm_display_name: "Cedar Grove Farm".to_owned(), - fulfillment_summary: "Thursday pickup".to_owned(), - status: BuyerOrderStatus::Scheduled, - repeat_demand: None, - }], - }; - let order_detail = BuyerOrderDetailProjection { - order_id, - farm_id, - order_number: "R-2001".to_owned(), - farm_display_name: "Cedar Grove Farm".to_owned(), - fulfillment_summary: "Thursday pickup".to_owned(), - status: BuyerOrderStatus::Scheduled, - items: vec![OrderDetailItemRow { - title: "Spring salad mix".to_owned(), - quantity_display: "2 bags".to_owned(), - }], - order_note: Some("Leave by the cooler".to_owned()), - repeat_demand: None, - }; - - assert!(!listings.is_empty()); - assert!(!cart.is_empty()); - assert!(checkout.can_place_order); - assert!(!orders.is_empty()); - assert_eq!(listing.fulfillment_methods.len(), 1); - assert_eq!(order_detail.status, BuyerOrderStatus::Scheduled); - } - - #[test] - fn today_agenda_stays_on_the_compact_order_row_contract() { - let today = TodayAgendaProjection { - orders_needing_action: vec![OrderListRow { - order_id: super::OrderId::new(), - farm_id: FarmId::new(), - fulfillment_window_id: Some(super::FulfillmentWindowId::new()), - order_number: "R-1002".to_owned(), - customer_display_name: "Morgan".to_owned(), - status: OrderStatus::NeedsAction, - }], - ..TodayAgendaProjection::default() - }; - let orders_row = OrdersListRow { - order_id: super::OrderId::new(), - farm_id: FarmId::new(), - fulfillment_window_id: None, - order_number: "R-2002".to_owned(), - customer_display_name: "Robin".to_owned(), - fulfillment_window_label: None, - pickup_location_label: None, - status: OrderStatus::Completed, - primary_action: None, - }; - - assert_eq!(today.orders_needing_action.len(), 1); - assert_eq!( - today.orders_needing_action[0].status, - OrderStatus::NeedsAction - ); - assert_eq!(orders_row.primary_action, None); - assert_eq!(orders_row.status, OrderStatus::Completed); - } - - #[test] - fn today_summary_attention_state_is_explicit() { - let quiet = TodaySummary { - farm_id: FarmId::new(), - orders_needing_action: 0, - low_stock_products: 0, - draft_products: 0, - reminders_due_soon: 0, - recovery_actions_open: 0, - }; - let busy = TodaySummary { - farm_id: FarmId::new(), - orders_needing_action: 1, - low_stock_products: 0, - draft_products: 0, - reminders_due_soon: 0, - recovery_actions_open: 0, - }; - - assert!(!quiet.has_attention_items()); - assert!(busy.has_attention_items()); - } - - #[test] - fn reminder_recovery_and_repeat_demand_contracts_are_explicit() { - let farm_id = FarmId::new(); - let order_id = OrderId::new(); - let fulfillment_window_id = FulfillmentWindowId::new(); - let reminder = ReminderDeadlineProjection { - reminder_id: ReminderId::new(), - farm_id, - order_id: Some(order_id), - fulfillment_window_id: Some(fulfillment_window_id), - kind: ReminderKind::FulfillmentWindow, - surface: ReminderSurface::Today, - urgency: ReminderUrgency::DueSoon, - title: "Pickup closes soon".to_owned(), - detail: "Pack before the pickup window opens.".to_owned(), - deadline_at: "2026-04-24T15:00:00Z".to_owned(), - action_label: Some("Open pack day".to_owned()), - delivery_state: ReminderDeliveryState::Scheduled, - }; - let recovery = OrderRecoveryProjection { - recovery_record_id: RecoveryRecordId::new(), - order_id, - kind: RecoveryKind::MissedPickup, - state: RecoveryState::Open, - summary: "Customer missed pickup".to_owned(), - note: Some("Hold one extra day".to_owned()), - last_updated_at: "2026-04-24T18:00:00Z".to_owned(), - }; - let repeat_demand = RepeatDemandHandoffProjection { - order_id, - farm_id, - eligibility: RepeatDemandEligibility::Partial, - available_item_count: 2, - unavailable_item_count: 1, - }; - - let reminder_feed = ReminderFeedProjection { - items: vec![reminder.clone()], - }; - let reminder_log = ReminderLogProjection { - entries: vec![ReminderLogEntryProjection { - reminder_id: reminder.reminder_id, - kind: reminder.kind, - title: reminder.title.clone(), - recorded_at: "2026-04-24T14:00:00Z".to_owned(), - delivery_state: ReminderDeliveryState::Presented, - detail: Some(reminder.detail.clone()), - }], - }; - let recovery_queue = RecoveryQueueProjection { - items: vec![recovery.clone()], - }; - - assert_eq!(ReminderSurface::PackDay.storage_key(), "pack_day"); - assert_eq!( - ReminderKind::RefundRecovery.storage_key(), - "refund_recovery" - ); - assert_eq!(ReminderUrgency::DueSoon.storage_key(), "due_soon"); - assert_eq!( - ReminderDeliveryState::Acknowledged.storage_key(), - "acknowledged" - ); - assert_eq!( - RecoveryKind::RefundFollowUp.storage_key(), - "refund_follow_up" - ); - assert_eq!(RecoveryState::InReview.storage_key(), "in_review"); - assert_eq!( - RepeatDemandEligibility::Unavailable.storage_key(), - "unavailable" - ); - assert_eq!(reminder_feed.due_soon_count(), 1); - assert!(!reminder_log.is_empty()); - assert!(!recovery_queue.is_empty()); - assert_eq!(repeat_demand.unavailable_item_count, 1); - } - - #[test] - fn today_agenda_projection_tracks_attention_and_setup_independently() { - let calm = TodayAgendaProjection::default(); - let with_attention = TodayAgendaProjection { - draft_products: vec![ProductListRow { - product_id: super::ProductId::new(), - farm_id: FarmId::new(), - title: "Spring onions".to_owned(), - status: super::ProductStatus::Draft, - stock_count: 0, - }], - ..TodayAgendaProjection::default() - }; - let with_setup = TodayAgendaProjection { - setup_checklist: vec![TodaySetupTask { - kind: TodaySetupTaskKind::AddFulfillmentWindow, - is_complete: false, - }], - ..TodayAgendaProjection::default() - }; - - assert!(!calm.has_attention_items()); - assert!(!calm.needs_setup()); - assert!(with_attention.has_attention_items()); - assert!(!with_attention.needs_setup()); - assert!(!with_setup.has_attention_items()); - assert!(with_setup.needs_setup()); - } - - #[test] - fn today_agenda_projection_can_hold_truthful_lists() { - let projection = TodayAgendaProjection { - orders_needing_action: vec![OrderListRow { - order_id: super::OrderId::new(), - farm_id: FarmId::new(), - fulfillment_window_id: Some(super::FulfillmentWindowId::new()), - order_number: "R-1001".to_owned(), - customer_display_name: "Casey".to_owned(), - status: super::OrderStatus::NeedsAction, - }], - low_stock_products: vec![ProductListRow { - product_id: super::ProductId::new(), - farm_id: FarmId::new(), - title: "Carrots".to_owned(), - status: super::ProductStatus::Published, - stock_count: 2, - }], - ..TodayAgendaProjection::default() - }; - - assert_eq!(projection.orders_needing_action.len(), 1); - assert_eq!(projection.low_stock_products[0].stock_count, 2); - assert!(projection.has_attention_items()); - } - - #[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 farm_rules_projection_defaults_to_missing_v1_requirements() { - let projection = FarmRulesProjection::default(); - - assert!(projection.farm_profile.is_none()); - assert!(projection.pickup_locations.is_empty()); - assert!(projection.operating_rules.is_none()); - assert!(projection.fulfillment_windows.is_empty()); - assert!(projection.blackout_periods.is_empty()); - assert_eq!( - projection.readiness, - FarmRulesReadiness::missing_v1_basics() - ); - assert!(!projection.is_ready()); - } - - #[test] - fn farm_rules_readiness_and_timing_conflicts_are_explicit() { - let readiness = FarmRulesReadiness { - blockers: vec![FarmReadinessBlocker::MissingOperatingRules], - timing_conflicts: vec![FarmTimingConflict { - kind: FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow, - fulfillment_window_id: Some(super::FulfillmentWindowId::new()), - blackout_period_id: Some(BlackoutPeriodId::new()), - }], - }; - - assert_eq!( - FarmReadinessBlocker::MissingProfileBasics.storage_key(), - "missing_profile_basics" - ); - assert_eq!( - FarmReadinessBlocker::MissingPickupLocation.storage_key(), - "missing_pickup_location" - ); - assert_eq!( - FarmReadinessBlocker::MissingFulfillmentWindow.storage_key(), - "missing_fulfillment_window" - ); - assert_eq!( - FarmReadinessBlocker::MissingOperatingRules.storage_key(), - "missing_operating_rules" - ); - assert_eq!( - FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart.storage_key(), - "fulfillment_window_ends_before_start" - ); - assert_eq!( - FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart.storage_key(), - "fulfillment_window_cutoff_after_start" - ); - assert_eq!( - FarmTimingConflictKind::BlackoutPeriodEndsBeforeStart.storage_key(), - "blackout_period_ends_before_start" - ); - assert_eq!( - FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow.storage_key(), - "blackout_overlaps_fulfillment_window" - ); - assert!(!readiness.is_ready()); - assert!(FarmRulesReadiness::ready().is_ready()); - } - - #[test] - fn farm_rules_projection_represents_full_v1_inventory() { - let farm_id = FarmId::new(); - let pickup_location_id = PickupLocationId::new(); - let fulfillment_window_id = super::FulfillmentWindowId::new(); - let blackout_period_id = BlackoutPeriodId::new(); - let projection = super::FarmRulesProjection { - farm_profile: Some(super::FarmProfileRecord { - farm_id, - display_name: "North field farm".to_owned(), - timezone: "UTC".to_owned(), - currency_code: "USD".to_owned(), - }), - pickup_locations: vec![super::PickupLocationRecord { - pickup_location_id, - farm_id, - label: "Barn pickup".to_owned(), - address_line: "14 Orchard Lane".to_owned(), - directions: Some("Drive to the red barn.".to_owned()), - is_default: true, - }], - operating_rules: Some(super::FarmOperatingRulesRecord { - farm_id, - promise_lead_hours: 24, - substitution_policy: "ask_customer".to_owned(), - missed_pickup_policy: "hold_next_window".to_owned(), - }), - fulfillment_windows: vec![super::FulfillmentWindowRecord { - fulfillment_window_id, - farm_id, - pickup_location_id, - label: "Friday pickup".to_owned(), - starts_at: "2026-04-25T14:00:00Z".to_owned(), - ends_at: "2026-04-25T18:00:00Z".to_owned(), - order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), - }], - blackout_periods: vec![super::BlackoutPeriodRecord { - blackout_period_id, - farm_id, - label: "Spring break".to_owned(), - starts_at: "2026-05-01T00:00:00Z".to_owned(), - ends_at: "2026-05-03T23:59:59Z".to_owned(), - }], - readiness: FarmRulesReadiness::ready(), - }; - let saved_farm = super::FarmSummary { - farm_id, - display_name: "North field farm".to_owned(), - readiness: super::FarmReadiness::Ready, - }; - - assert!(projection.is_ready()); - assert_eq!( - projection - .farm_profile - .as_ref() - .map(|profile| profile.display_name.as_str()), - Some(saved_farm.display_name.as_str()) - ); - assert_eq!( - projection.pickup_locations[0].pickup_location_id, - pickup_location_id - ); - assert_eq!( - projection.fulfillment_windows[0].pickup_location_id, - pickup_location_id - ); - assert_eq!( - projection.blackout_periods[0].blackout_period_id, - blackout_period_id - ); - assert_eq!(saved_farm.readiness, super::FarmReadiness::Ready); - } - - #[test] - fn settings_preference_storage_keys_are_stable() { - assert_eq!( - SettingsPreference::AllowRelayConnections.storage_key(), - "allow_relay_connections" - ); - assert_eq!( - SettingsPreference::UseMediaServers.storage_key(), - "use_media_servers" - ); - assert_eq!(SettingsPreference::UseNip05.storage_key(), "use_nip05"); - assert_eq!( - SettingsPreference::LaunchAtLogin.storage_key(), - "launch_at_login" - ); - } - - #[test] - fn activity_kind_storage_keys_are_stable() { - assert_eq!(AppActivityKind::HomeOpened.storage_key(), "home_opened"); - assert_eq!( - AppActivityKind::SettingsOpened { - section: SettingsSection::About, - } - .storage_key(), - "settings_opened" - ); - assert_eq!( - AppActivityKind::SettingsSectionSelected { - section: SettingsSection::Settings, - } - .storage_key(), - "settings_section_selected" - ); - assert_eq!( - AppActivityKind::SettingsPreferenceUpdated { - preference: SettingsPreference::LaunchAtLogin, - enabled: true, - } - .storage_key(), - "settings_preference_updated" - ); - } - - #[test] - fn activity_context_preserves_recent_event_order() { - let first = AppActivityEvent { - activity_event_id: ActivityEventId::new(), - recorded_at: "2026-04-18T00:00:00.000Z".to_owned(), - kind: AppActivityKind::HomeOpened, - }; - let second = AppActivityEvent { - activity_event_id: ActivityEventId::new(), - recorded_at: "2026-04-18T00:01:00.000Z".to_owned(), - kind: AppActivityKind::SettingsOpened { - section: SettingsSection::About, - }, - }; - let context = AppActivityContext::from_recent_events(vec![second.clone(), first.clone()]); - - assert_eq!(context.recent_events, vec![second, first]); - } -} diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml @@ -8,7 +8,7 @@ license.workspace = true publish = false [dependencies] -radroots_app_models.workspace = true +radroots_app_view.workspace = true radroots_app_sync.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs @@ -7,7 +7,11 @@ use std::{ path::{Path, PathBuf}, }; -use radroots_app_models::{ +use radroots_app_sync::{ + AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, SyncConflict, + SyncConflictStatus, +}; +use radroots_app_view::{ ActiveSurface, AppIdentityProjection, AppStartupGate, BuyerCartProjection, BuyerCheckoutProjection, BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrdersProjection, BuyerProductDetailProjection, FarmOrderMethod, FarmReadiness, @@ -24,10 +28,6 @@ use radroots_app_models::{ SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; -use radroots_app_sync::{ - AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, SyncConflict, - SyncConflictStatus, -}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::error; @@ -2250,7 +2250,12 @@ mod tests { ProductsScreenProjection, ProductsScreenQueryState, SettingsPreference, derive_sync_projection, derive_sync_run_status, }; - use radroots_app_models::{ + use radroots_app_sync::{ + AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, + SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, + SyncConflictStatus, + }; + use radroots_app_view::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmRulesReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, @@ -2270,11 +2275,6 @@ mod tests { ReminderLogProjection, SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; - use radroots_app_sync::{ - AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, - SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, - SyncConflictStatus, - }; struct FailingRepository; @@ -2443,7 +2443,7 @@ mod tests { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); let products_list = ProductsListProjection { - summary: radroots_app_models::ProductsListSummary { + summary: radroots_app_view::ProductsListSummary { total_products: 2, live_products: 1, draft_products: 1, @@ -2535,27 +2535,27 @@ mod tests { recoveries: Vec::new(), }; let orders_reminders = ReminderFeedProjection { - items: vec![radroots_app_models::ReminderDeadlineProjection { - reminder_id: radroots_app_models::ReminderId::new(), + items: vec![radroots_app_view::ReminderDeadlineProjection { + reminder_id: radroots_app_view::ReminderId::new(), farm_id, order_id: Some(order_id), fulfillment_window_id: Some(fulfillment_window_id), - kind: radroots_app_models::ReminderKind::OrderAction, - surface: radroots_app_models::ReminderSurface::Orders, - urgency: radroots_app_models::ReminderUrgency::DueSoon, + kind: radroots_app_view::ReminderKind::OrderAction, + surface: radroots_app_view::ReminderSurface::Orders, + urgency: radroots_app_view::ReminderUrgency::DueSoon, title: "review order".to_owned(), detail: "Casey still needs confirmation.".to_owned(), deadline_at: "2026-04-18T15:00:00Z".to_owned(), action_label: Some("Review".to_owned()), - delivery_state: radroots_app_models::ReminderDeliveryState::Scheduled, + delivery_state: radroots_app_view::ReminderDeliveryState::Scheduled, }], }; - let recovery_queue = radroots_app_models::RecoveryQueueProjection { - items: vec![radroots_app_models::OrderRecoveryProjection { - recovery_record_id: radroots_app_models::RecoveryRecordId::new(), + let recovery_queue = radroots_app_view::RecoveryQueueProjection { + items: vec![radroots_app_view::OrderRecoveryProjection { + recovery_record_id: radroots_app_view::RecoveryRecordId::new(), order_id, - kind: radroots_app_models::RecoveryKind::MissedPickup, - state: radroots_app_models::RecoveryState::Open, + kind: radroots_app_view::RecoveryKind::MissedPickup, + state: radroots_app_view::RecoveryState::Open, summary: "Follow up on pickup".to_owned(), note: None, last_updated_at: "2026-04-18T19:00:00Z".to_owned(), @@ -2572,7 +2572,7 @@ mod tests { }], }; let pack_day = PackDayProjection { - fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { + fulfillment_window: Some(radroots_app_view::FulfillmentWindowSummary { fulfillment_window_id, farm_id, starts_at: "2026-04-18T16:00:00Z".to_owned(), @@ -3296,7 +3296,7 @@ mod tests { ); let next_pack_day = PackDayProjection { - fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { + fulfillment_window: Some(radroots_app_view::FulfillmentWindowSummary { fulfillment_window_id: next_window_id, farm_id, starts_at: "2026-04-25T16:00:00Z".to_owned(), @@ -3337,7 +3337,7 @@ mod tests { ); let next_pack_day = PackDayProjection { - fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { + fulfillment_window: Some(radroots_app_view::FulfillmentWindowSummary { fulfillment_window_id: next_window_id, farm_id, starts_at: "2026-04-25T16:00:00Z".to_owned(), @@ -3377,7 +3377,7 @@ mod tests { ); let next_pack_day = PackDayProjection { - fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { + fulfillment_window: Some(radroots_app_view::FulfillmentWindowSummary { fulfillment_window_id: next_window_id, farm_id, starts_at: "2026-04-25T16:00:00Z".to_owned(), @@ -3489,7 +3489,7 @@ mod tests { price_currency: "USD".to_owned(), stock_quantity: Some(12), availability_window_id: Some(FulfillmentWindowId::new()), - status: radroots_app_models::ProductStatus::Draft, + status: radroots_app_view::ProductStatus::Draft, }; assert_eq!( @@ -3577,7 +3577,7 @@ mod tests { price_currency: "USD".to_owned(), stock_quantity: Some(12), availability_window_id: Some(active_window_id), - status: radroots_app_models::ProductStatus::Published, + status: radroots_app_view::ProductStatus::Published, }; let stale_draft = ProductEditorDraft { availability_window_id: Some(stale_window_id), @@ -3653,7 +3653,7 @@ mod tests { price_currency: "USD".to_owned(), stock_quantity: Some(12), availability_window_id: Some(active_window_id), - status: radroots_app_models::ProductStatus::Published, + status: radroots_app_view::ProductStatus::Published, }, publish_blockers: Vec::new(), }) @@ -3995,7 +3995,7 @@ mod tests { AppStateStore::load(FailingRepository).expect("failing repository should still load"); let farm_id = FarmId::new(); let today = TodayAgendaProjection { - farm: Some(radroots_app_models::FarmSummary { + farm: Some(radroots_app_view::FarmSummary { farm_id, display_name: "North field farm".to_owned(), readiness: FarmReadiness::Incomplete, @@ -4097,7 +4097,7 @@ mod tests { let changed = store.apply(AppStateCommand::replace_today_agenda( TodayAgendaProjection { - farm: Some(radroots_app_models::FarmSummary { + farm: Some(radroots_app_view::FarmSummary { farm_id, display_name: "North field farm".to_owned(), readiness: FarmReadiness::Ready, diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml @@ -12,7 +12,7 @@ radroots_core.workspace = true radroots_events.workspace = true radroots_events_codec.workspace = true radroots_local_events.workspace = true -radroots_app_models.workspace = true +radroots_app_view.workspace = true radroots_app_sync.workspace = true radroots_sql_core.workspace = true rusqlite.workspace = true diff --git a/crates/store/src/activation.rs b/crates/store/src/activation.rs @@ -1,4 +1,4 @@ -use radroots_app_models::{ +use radroots_app_view::{ AccountSurfaceActivationProjection, ActiveSurface, FarmId, FarmerActivationProjection, SelectedSurfaceProjection, }; @@ -133,7 +133,7 @@ fn parse_optional_farm_id( #[cfg(test)] mod tests { - use radroots_app_models::{ + use radroots_app_view::{ AccountSurfaceActivationProjection, ActiveSurface, FarmId, FarmerActivationProjection, SelectedSurfaceProjection, }; diff --git a/crates/store/src/activity.rs b/crates/store/src/activity.rs @@ -1,4 +1,4 @@ -use radroots_app_models::{ +use radroots_app_view::{ ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, SettingsPreference, SettingsSection, }; @@ -265,7 +265,7 @@ fn preference_enabled_value(kind: &AppActivityKind) -> Option<i64> { #[cfg(test)] mod tests { - use radroots_app_models::{AppActivityKind, SettingsPreference, SettingsSection}; + use radroots_app_view::{AppActivityKind, SettingsPreference, SettingsSection}; use rusqlite::Connection; use crate::{AppSqliteStore, DatabaseTarget}; diff --git a/crates/store/src/buyer.rs b/crates/store/src/buyer.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use radroots_app_models::{ +use radroots_app_view::{ BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, BuyerCheckoutDisabledReason, BuyerCheckoutDraft, BuyerCheckoutProjection, BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, BuyerListingsProjection, @@ -2736,7 +2736,7 @@ fn normalized_listing_relays<'a>(relays: impl IntoIterator<Item = &'a str>) -> V mod tests { use std::collections::BTreeSet; - use radroots_app_models::{ + use radroots_app_view::{ BuyerCheckoutDisabledReason, BuyerContext, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderId, PickupLocationId, ProductId, }; @@ -2924,10 +2924,10 @@ mod tests { repository .replace_buyer_cart( &context, - &radroots_app_models::BuyerCartProjection { + &radroots_app_view::BuyerCartProjection { farm_id: Some(farm_id), farm_display_name: Some("Willow Farm".to_owned()), - lines: vec![radroots_app_models::BuyerCartLineProjection { + lines: vec![radroots_app_view::BuyerCartLineProjection { product_id: listing.product_id, farm_id: listing.farm_id, farm_display_name: listing.farm_display_name.clone(), @@ -2946,7 +2946,7 @@ mod tests { repository .save_buyer_checkout_draft( &context, - &radroots_app_models::BuyerCheckoutDraft { + &radroots_app_view::BuyerCheckoutDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: "555-0101".to_owned(), @@ -3042,11 +3042,11 @@ mod tests { repository .replace_buyer_cart( &context, - &radroots_app_models::BuyerCartProjection { + &radroots_app_view::BuyerCartProjection { farm_id: Some(farm_id), farm_display_name: Some("Willow Farm".to_owned()), lines: vec![ - radroots_app_models::BuyerCartLineProjection { + radroots_app_view::BuyerCartLineProjection { product_id: available_listing.product_id, farm_id: available_listing.farm_id, farm_display_name: available_listing.farm_display_name.clone(), @@ -3056,7 +3056,7 @@ mod tests { line_total_minor_units: 1300, fulfillment_summary: "Friday pickup".to_owned(), }, - radroots_app_models::BuyerCartLineProjection { + radroots_app_view::BuyerCartLineProjection { product_id: unavailable_listing.product_id, farm_id: unavailable_listing.farm_id, farm_display_name: unavailable_listing.farm_display_name.clone(), @@ -3076,7 +3076,7 @@ mod tests { repository .save_buyer_checkout_draft( &context, - &radroots_app_models::BuyerCheckoutDraft { + &radroots_app_view::BuyerCheckoutDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: String::new(), @@ -3114,7 +3114,7 @@ mod tests { assert_eq!(buyer_orders.rows.len(), 1); assert_eq!( row_repeat_demand.eligibility, - radroots_app_models::RepeatDemandEligibility::Partial + radroots_app_view::RepeatDemandEligibility::Partial ); assert_eq!(row_repeat_demand.available_item_count, 1); assert_eq!(row_repeat_demand.unavailable_item_count, 1); @@ -3162,10 +3162,10 @@ mod tests { repository .replace_buyer_cart( &context, - &radroots_app_models::BuyerCartProjection { + &radroots_app_view::BuyerCartProjection { farm_id: Some(farm_id), farm_display_name: Some("Willow Farm".to_owned()), - lines: vec![radroots_app_models::BuyerCartLineProjection { + lines: vec![radroots_app_view::BuyerCartLineProjection { product_id: listing.product_id, farm_id: listing.farm_id, farm_display_name: listing.farm_display_name.clone(), @@ -3184,7 +3184,7 @@ mod tests { repository .save_buyer_checkout_draft( &context, - &radroots_app_models::BuyerCheckoutDraft { + &radroots_app_view::BuyerCheckoutDraft { name: "Casey Buyer".to_owned(), email: "casey@example.com".to_owned(), phone: String::new(), @@ -3212,7 +3212,7 @@ mod tests { assert_eq!( repeat_demand.eligibility, - radroots_app_models::RepeatDemandEligibility::Unavailable + radroots_app_view::RepeatDemandEligibility::Unavailable ); assert_eq!(repeat_demand.available_item_count, 0); assert_eq!(repeat_demand.unavailable_item_count, 1); @@ -3304,16 +3304,16 @@ mod tests { AppBuyerRepository::new(store.connection()) .replace_buyer_cart( &BuyerContext::Guest, - &radroots_app_models::BuyerCartProjection { + &radroots_app_view::BuyerCartProjection { farm_id: Some(farm_id), farm_display_name: Some("Willow Farm".to_owned()), - lines: vec![radroots_app_models::BuyerCartLineProjection { + lines: vec![radroots_app_view::BuyerCartLineProjection { product_id: ProductId::new(), farm_id: other_farm_id, farm_display_name: "Other Farm".to_owned(), title: "Mismatch".to_owned(), quantity: 1, - unit_price: radroots_app_models::ProductPricePresentation { + unit_price: radroots_app_view::ProductPricePresentation { amount_minor_units: 500, currency_code: "USD".to_owned(), unit_label: "bag".to_owned(), diff --git a/crates/store/src/farm_rules.rs b/crates/store/src/farm_rules.rs @@ -1,6 +1,6 @@ use std::{fmt, str::FromStr}; -use radroots_app_models::{ +use radroots_app_view::{ BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmTimingConflict, FarmTimingConflictKind, FulfillmentWindowRecord, PickupLocationRecord, @@ -939,7 +939,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use radroots_app_models::{ + use radroots_app_view::{ BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmTimingConflictKind, FulfillmentWindowId, FulfillmentWindowRecord, PickupLocationId, diff --git a/crates/store/src/farm_setup.rs b/crates/store/src/farm_setup.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use radroots_app_models::{ +use radroots_app_view::{ FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, }; use rusqlite::{Connection, OptionalExtension, params}; @@ -245,7 +245,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use radroots_app_models::{ + use radroots_app_view::{ FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, }; diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs @@ -16,7 +16,11 @@ mod today; use std::{collections::BTreeSet, fs, path::PathBuf, time::Duration}; -use radroots_app_models::{ +use radroots_app_sync::{ + AppRelayIngestScopeFreshness, PendingSyncOperation, SyncCheckpointStatus, SyncConflict, + SyncConflictResolutionStatus, +}; +use radroots_app_view::{ AccountSurfaceActivationProjection, AppActivityContext, AppActivityEvent, AppActivityKind, BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection, BuyerContext, BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrdersProjection, @@ -28,10 +32,6 @@ use radroots_app_models::{ RecoveryQueueProjection, ReminderFeedProjection, ReminderLogEntryProjection, ReminderLogProjection, TodayAgendaProjection, }; -use radroots_app_sync::{ - AppRelayIngestScopeFreshness, PendingSyncOperation, SyncCheckpointStatus, SyncConflict, - SyncConflictResolutionStatus, -}; use rusqlite::Connection; pub use activation::AppActivationRepository; diff --git a/crates/store/src/local_interop.rs b/crates/store/src/local_interop.rs @@ -1,6 +1,6 @@ use std::{fs, path::Path}; -use radroots_app_models::{ +use radroots_app_view::{ FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FulfillmentWindowId, OrderId, PickupLocationId, ProductId, ProductStatus, }; @@ -2981,7 +2981,7 @@ fn farm_readiness_from_storage_key(readiness: &str) -> Result<FarmReadiness, App mod tests { use std::collections::BTreeSet; - use radroots_app_models::{ + use radroots_app_view::{ BuyerContext, BuyerOrderStatus, FarmId, FarmOrderMethod, OrderStatus, OrdersFilter, OrdersScreenQueryState, ProductAvailabilityState, ProductId, }; @@ -4305,7 +4305,7 @@ mod tests { assert_eq!(products.rows[0].stock.quantity, Some(10)); assert_eq!( products.rows[0].status, - radroots_app_models::ProductStatus::Draft + radroots_app_view::ProductStatus::Draft ); } @@ -5077,10 +5077,10 @@ mod tests { app_store .replace_buyer_cart( &buyer_context, - &radroots_app_models::BuyerCartProjection { + &radroots_app_view::BuyerCartProjection { farm_id: Some(network_listing.farm_id), farm_display_name: Some(network_listing.farm_display_name.clone()), - lines: vec![radroots_app_models::BuyerCartLineProjection { + lines: vec![radroots_app_view::BuyerCartLineProjection { product_id: network_listing.product_id, farm_id: network_listing.farm_id, farm_display_name: network_listing.farm_display_name.clone(), @@ -5102,7 +5102,7 @@ mod tests { app_store .save_buyer_checkout_draft( &buyer_context, - &radroots_app_models::BuyerCheckoutDraft { + &radroots_app_view::BuyerCheckoutDraft { name: "Casey Buyer".to_owned(), email: "casey@example.test".to_owned(), phone: String::new(), @@ -5116,10 +5116,10 @@ mod tests { app_store .replace_buyer_cart( &buyer_context, - &radroots_app_models::BuyerCartProjection { + &radroots_app_view::BuyerCartProjection { farm_id: Some(network_listing.farm_id), farm_display_name: Some(network_listing.farm_display_name.clone()), - lines: vec![radroots_app_models::BuyerCartLineProjection { + lines: vec![radroots_app_view::BuyerCartLineProjection { product_id: network_listing.product_id, farm_id: network_listing.farm_id, farm_display_name: network_listing.farm_display_name.clone(), diff --git a/crates/store/src/orders.rs b/crates/store/src/orders.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use radroots_app_models::{ +use radroots_app_view::{ FarmId, FulfillmentWindowId, FulfillmentWindowSummary, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, @@ -1216,7 +1216,7 @@ fn empty_string_to_none(value: Option<String>) -> Option<String> { #[cfg(test)] mod tests { - use radroots_app_models::{ + use radroots_app_view::{ FarmId, FulfillmentWindowId, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersScreenQueryState, PackDayOutputOrderState, PackDayProductTotalRow, PackDayScreenQueryState, PickupLocationId, diff --git a/crates/store/src/products.rs b/crates/store/src/products.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; -use radroots_app_models::{ +use radroots_app_view::{ FarmId, FulfillmentWindowId, ProductAttentionState, ProductAvailabilityState, ProductAvailabilitySummary, ProductEditorDraft, ProductId, ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductStockState, ProductStockSummary, ProductsFilter, @@ -728,7 +728,7 @@ fn parse_product_status( #[cfg(test)] mod tests { - use radroots_app_models::{ + use radroots_app_view::{ FarmId, FulfillmentWindowId, ProductAttentionState, ProductAvailabilityState, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductStatus, ProductStockState, ProductsFilter, ProductsSort, diff --git a/crates/store/src/reminders.rs b/crates/store/src/reminders.rs @@ -1,4 +1,4 @@ -use radroots_app_models::{ +use radroots_app_view::{ FarmId, OrderId, OrderRecoveryProjection, RecoveryKind, RecoveryQueueProjection, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, @@ -627,7 +627,7 @@ where mod tests { use super::AppRemindersRepository; use crate::{AppSqliteStore, DatabaseTarget}; - use radroots_app_models::{ + use radroots_app_view::{ FarmId, OrderId, OrderRecoveryProjection, RecoveryKind, RecoveryRecordId, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId, ReminderKind, ReminderLogEntryProjection, ReminderSurface, ReminderUrgency, diff --git a/crates/store/src/sync.rs b/crates/store/src/sync.rs @@ -1,10 +1,10 @@ -use radroots_app_models::{FarmId, FulfillmentWindowId, OrderId, ProductId}; use radroots_app_sync::{ AppRelayIngestFreshnessState, AppRelayIngestRelayFreshness, AppRelayIngestScopeFreshness, AppRelayIngestScopeStatus, PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncCheckpointState, SyncCheckpointStatus, SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncOperationKind, }; +use radroots_app_view::{FarmId, FulfillmentWindowId, OrderId, ProductId}; use rusqlite::{Connection, OptionalExtension, params}; use uuid::Uuid; @@ -1001,12 +1001,12 @@ fn relay_ingest_scope_status(relays: &[AppRelayIngestRelayFreshness]) -> AppRela #[cfg(test)] mod tests { - use radroots_app_models::{FarmId, ProductId}; use radroots_app_sync::{ AppRelayIngestFreshnessState, AppRelayIngestScopeStatus, PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncCheckpointStatus, SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncOperationKind, }; + use radroots_app_view::{FarmId, ProductId}; use crate::{AppSqliteStore, DatabaseTarget}; diff --git a/crates/store/src/today.rs b/crates/store/src/today.rs @@ -1,4 +1,4 @@ -use radroots_app_models::{ +use radroots_app_view::{ FarmId, FarmReadiness, FarmSummary, FulfillmentWindowSummary, OrderListRow, OrderStatus, ProductListRow, ProductStatus, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, @@ -434,7 +434,7 @@ fn farm_readiness_storage_key(readiness: FarmReadiness) -> &'static str { #[cfg(test)] mod tests { - use radroots_app_models::{FarmId, FulfillmentWindowId, ProductId, TodaySetupTaskKind}; + use radroots_app_view::{FarmId, FulfillmentWindowId, ProductId, TodaySetupTaskKind}; use rusqlite::{Connection, params}; use crate::{AppSqliteStore, DatabaseTarget}; @@ -451,7 +451,7 @@ mod tests { assert_eq!( projection, - radroots_app_models::TodayAgendaProjection::default() + radroots_app_view::TodayAgendaProjection::default() ); } @@ -674,10 +674,10 @@ mod tests { #[test] fn saved_farm_summary_round_trips_into_today_projection() { let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); - let farm = radroots_app_models::FarmSummary { + let farm = radroots_app_view::FarmSummary { farm_id: FarmId::new(), display_name: "North field farm".to_owned(), - readiness: radroots_app_models::FarmReadiness::Incomplete, + readiness: radroots_app_view::FarmReadiness::Incomplete, }; store @@ -775,7 +775,7 @@ mod tests { "insert into orders (id, farm_id, fulfillment_window_id, order_number, customer_display_name, status, updated_at) \ values (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ - radroots_app_models::OrderId::new().to_string(), + radroots_app_view::OrderId::new().to_string(), farm_id.to_string(), fulfillment_window_id.map(|id| id.to_string()), order_number, diff --git a/crates/sync/Cargo.toml b/crates/sync/Cargo.toml @@ -8,7 +8,7 @@ license.workspace = true publish = false [dependencies] -radroots_app_models.workspace = true +radroots_app_view.workspace = true radroots_sdk.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs @@ -10,7 +10,7 @@ pub use publish::{ AppPublishWorkKind, }; -use radroots_app_models::{FarmId, FulfillmentWindowId, OrderId, ProductId}; +use radroots_app_view::{FarmId, FulfillmentWindowId, OrderId, ProductId}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -539,7 +539,7 @@ mod tests { SyncConflictResolutionStatus, SyncConflictSeverity, SyncConflictStatus, SyncOperationKind, SyncTrigger, }; - use radroots_app_models::{FarmId, ProductId}; + use radroots_app_view::{FarmId, ProductId}; #[test] fn default_projection_starts_idle_and_clear() { diff --git a/crates/sync/src/publish.rs b/crates/sync/src/publish.rs @@ -1,4 +1,4 @@ -use radroots_app_models::{ +use radroots_app_view::{ FarmId, FarmReadiness, FulfillmentWindowId, OrderId, ProductId, ProductStatus, }; use radroots_sdk::SdkTransportMode; @@ -502,7 +502,7 @@ mod tests { use crate::{ PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind, }; - use radroots_app_models::{FarmId, FarmReadiness, OrderId, ProductId, ProductStatus}; + use radroots_app_view::{FarmId, FarmReadiness, OrderId, ProductId, ProductStatus}; #[test] fn publish_payload_serializes_with_stable_kind_and_sdk_target() { diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "radroots_app_types" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +serde.workspace = true +uuid.workspace = true + +[lints] +workspace = true diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs @@ -0,0 +1,990 @@ +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use std::{fmt, str::FromStr}; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SettingsSection { + #[default] + Account, + Farm, + Settings, + About, +} + +impl SettingsSection { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Account => "settings.account", + Self::Farm => "settings.farm", + Self::Settings => "settings.settings", + Self::About => "settings.about", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SettingsPreference { + AllowRelayConnections, + UseMediaServers, + UseNip05, + LaunchAtLogin, +} + +impl SettingsPreference { + pub const fn storage_key(self) -> &'static str { + match self { + Self::AllowRelayConnections => "allow_relay_connections", + Self::UseMediaServers => "use_media_servers", + Self::UseNip05 => "use_nip05", + Self::LaunchAtLogin => "launch_at_login", + } + } +} + +macro_rules! typed_id { + ($name:ident) => { + #[derive( + Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize, + )] + #[serde(transparent)] + pub struct $name(Uuid); + + impl $name { + pub fn new() -> Self { + Self(Uuid::now_v7()) + } + + pub fn as_uuid(self) -> Uuid { + self.0 + } + } + + impl From<Uuid> for $name { + fn from(value: Uuid) -> Self { + Self(value) + } + } + + impl From<$name> for Uuid { + fn from(value: $name) -> Self { + value.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } + } + + impl FromStr for $name { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Uuid::parse_str(value).map(Self) + } + } + + impl TryFrom<&str> for $name { + type Error = uuid::Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + value.parse() + } + } + }; +} + +typed_id!(FarmId); + +typed_id!(PickupLocationId); + +typed_id!(BlackoutPeriodId); + +typed_id!(ProductId); + +typed_id!(OrderId); + +typed_id!(FulfillmentWindowId); + +typed_id!(PackDayExportInstanceId); + +typed_id!(ActivityEventId); + +typed_id!(ReminderId); + +typed_id!(RecoveryRecordId); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountCustody { + LocalManaged, + BrowserSigner, + RemoteSigner, +} + +impl AccountCustody { + pub const fn storage_key(self) -> &'static str { + match self { + Self::LocalManaged => "local_managed", + Self::BrowserSigner => "browser_signer", + Self::RemoteSigner => "remote_signer", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmReadiness { + Incomplete, + Ready, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmProfileRecord { + pub farm_id: FarmId, + pub display_name: String, + pub timezone: String, + pub currency_code: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmOperatingRulesRecord { + pub farm_id: FarmId, + pub promise_lead_hours: u16, + pub substitution_policy: String, + pub missed_pickup_policy: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PickupLocationRecord { + pub pickup_location_id: PickupLocationId, + pub farm_id: FarmId, + pub label: String, + pub address_line: String, + pub directions: Option<String>, + pub is_default: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FulfillmentWindowRecord { + pub fulfillment_window_id: FulfillmentWindowId, + pub farm_id: FarmId, + pub pickup_location_id: PickupLocationId, + pub label: String, + pub starts_at: String, + pub ends_at: String, + pub order_cutoff_at: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BlackoutPeriodRecord { + pub blackout_period_id: BlackoutPeriodId, + pub farm_id: FarmId, + pub label: String, + pub starts_at: String, + pub ends_at: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmReadinessBlocker { + MissingProfileBasics, + MissingPickupLocation, + MissingFulfillmentWindow, + MissingOperatingRules, +} + +impl FarmReadinessBlocker { + pub const fn storage_key(self) -> &'static str { + match self { + Self::MissingProfileBasics => "missing_profile_basics", + Self::MissingPickupLocation => "missing_pickup_location", + Self::MissingFulfillmentWindow => "missing_fulfillment_window", + Self::MissingOperatingRules => "missing_operating_rules", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmTimingConflictKind { + FulfillmentWindowEndsBeforeStart, + FulfillmentWindowCutoffAfterStart, + BlackoutPeriodEndsBeforeStart, + BlackoutOverlapsFulfillmentWindow, +} + +impl FarmTimingConflictKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::FulfillmentWindowEndsBeforeStart => "fulfillment_window_ends_before_start", + Self::FulfillmentWindowCutoffAfterStart => "fulfillment_window_cutoff_after_start", + Self::BlackoutPeriodEndsBeforeStart => "blackout_period_ends_before_start", + Self::BlackoutOverlapsFulfillmentWindow => "blackout_overlaps_fulfillment_window", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmTimingConflict { + pub kind: FarmTimingConflictKind, + pub fulfillment_window_id: Option<FulfillmentWindowId>, + pub blackout_period_id: Option<BlackoutPeriodId>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmRulesReadiness { + pub blockers: Vec<FarmReadinessBlocker>, + pub timing_conflicts: Vec<FarmTimingConflict>, +} + +impl FarmRulesReadiness { + pub fn ready() -> Self { + Self { + blockers: Vec::new(), + timing_conflicts: Vec::new(), + } + } + + pub fn missing_v1_basics() -> Self { + Self { + blockers: vec![ + FarmReadinessBlocker::MissingProfileBasics, + FarmReadinessBlocker::MissingPickupLocation, + FarmReadinessBlocker::MissingFulfillmentWindow, + FarmReadinessBlocker::MissingOperatingRules, + ], + timing_conflicts: Vec::new(), + } + } + + pub fn is_ready(&self) -> bool { + self.blockers.is_empty() && self.timing_conflicts.is_empty() + } +} + +impl Default for FarmRulesReadiness { + fn default() -> Self { + Self::ready() + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductStatus { + #[default] + Draft, + Published, + Paused, + Archived, +} + +impl ProductStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::Published => "published", + Self::Paused => "paused", + Self::Archived => "archived", + } + } + + pub const fn is_live(self) -> bool { + matches!(self, Self::Published) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductPublishBlocker { + AddProductName, + ChooseCategory, + ChooseUnit, + SetPrice, + SetStock, + AttachAvailability, + CompleteFarmProfile, + AddPickupLocation, + AddOperatingRules, + AddFulfillmentWindow, + ResolveAvailabilityConflicts, +} + +impl ProductPublishBlocker { + pub const fn storage_key(self) -> &'static str { + match self { + Self::AddProductName => "add_product_name", + Self::ChooseCategory => "choose_category", + Self::ChooseUnit => "choose_unit", + Self::SetPrice => "set_price", + Self::SetStock => "set_stock", + 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", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrderStatus { + NeedsAction, + Scheduled, + Packed, + Completed, + Declined, + Refunded, +} + +impl OrderStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::NeedsAction => "needs_action", + Self::Scheduled => "scheduled", + Self::Packed => "packed", + Self::Completed => "completed", + Self::Declined => "declined", + Self::Refunded => "refunded", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BuyerOrderStatus { + Placed, + Scheduled, + Ready, + Completed, + Declined, + Refunded, +} + +impl BuyerOrderStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Placed => "placed", + Self::Scheduled => "scheduled", + Self::Ready => "ready", + Self::Completed => "completed", + Self::Declined => "declined", + Self::Refunded => "refunded", + } + } +} + +impl From<OrderStatus> for BuyerOrderStatus { + fn from(value: OrderStatus) -> Self { + match value { + OrderStatus::NeedsAction => Self::Placed, + OrderStatus::Scheduled => Self::Scheduled, + OrderStatus::Packed => Self::Ready, + OrderStatus::Completed => Self::Completed, + OrderStatus::Declined => Self::Declined, + OrderStatus::Refunded => Self::Refunded, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayExportArtifactKind { + PackSheet, + PickupRoster, + CustomerLabels, +} + +impl PackDayExportArtifactKind { + pub const fn all_v1() -> [Self; 3] { + [Self::PackSheet, Self::PickupRoster, Self::CustomerLabels] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::PackSheet => "pack_sheet", + Self::PickupRoster => "pickup_roster", + Self::CustomerLabels => "customer_labels", + } + } + + pub const fn file_name(self) -> &'static str { + match self { + Self::PackSheet => "pack_sheet.txt", + Self::PickupRoster => "pickup_roster.txt", + Self::CustomerLabels => "customer_labels.txt", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayPrintKind { + PrintPackSheet, + PrintPickupRoster, + PrintCustomerLabels, +} + +impl PackDayPrintKind { + pub const fn all_v1() -> [Self; 3] { + [ + Self::PrintPackSheet, + Self::PrintPickupRoster, + Self::PrintCustomerLabels, + ] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::PrintPackSheet => "print_pack_sheet", + Self::PrintPickupRoster => "print_pickup_roster", + Self::PrintCustomerLabels => "print_customer_labels", + } + } + + pub const fn artifact_kind(self) -> PackDayExportArtifactKind { + match self { + Self::PrintPackSheet => PackDayExportArtifactKind::PackSheet, + Self::PrintPickupRoster => PackDayExportArtifactKind::PickupRoster, + Self::PrintCustomerLabels => PackDayExportArtifactKind::CustomerLabels, + } + } + + pub const fn label_stock(self) -> Option<PackDayPrintLabelStock> { + match self { + Self::PrintPackSheet | Self::PrintPickupRoster => None, + Self::PrintCustomerLabels => Some(PackDayPrintLabelStock::Avery5160Letter30Up), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayPrintLabelStock { + Avery5160Letter30Up, +} + +impl PackDayPrintLabelStock { + pub const fn all_v1() -> [Self; 1] { + [Self::Avery5160Letter30Up] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::Avery5160Letter30Up => "avery_5160_letter_30_up", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayPrintFailureKind { + CustomerLabelsAvery5160Overflow, +} + +impl PackDayPrintFailureKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::CustomerLabelsAvery5160Overflow => "customer_labels_avery_5160_overflow", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayBatchPrintArtifact { + pub print_kind: PackDayPrintKind, + pub artifact_kind: PackDayExportArtifactKind, + pub label_stock: Option<PackDayPrintLabelStock>, +} + +impl PackDayBatchPrintArtifact { + pub const fn all_v1() -> [Self; 3] { + [ + Self::from_print_kind(PackDayPrintKind::PrintPackSheet), + Self::from_print_kind(PackDayPrintKind::PrintPickupRoster), + Self::from_print_kind(PackDayPrintKind::PrintCustomerLabels), + ] + } + + pub const fn from_print_kind(print_kind: PackDayPrintKind) -> Self { + Self { + print_kind, + artifact_kind: print_kind.artifact_kind(), + label_stock: print_kind.label_stock(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayBatchPrintFailureKind { + Preflight, + QueueLaunch, + QueueExit, + CustomerLabelsAvery5160Overflow, +} + +impl PackDayBatchPrintFailureKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Preflight => "preflight", + Self::QueueLaunch => "queue_launch", + Self::QueueExit => "queue_exit", + Self::CustomerLabelsAvery5160Overflow => "customer_labels_avery_5160_overflow", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayBatchPrintStatus { + #[default] + Idle, + Running, + Succeeded, + Failed, +} + +impl PackDayBatchPrintStatus { + 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, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayPrintStatus { + #[default] + Idle, + Running, + Succeeded, + Failed, +} + +impl PackDayPrintStatus { + 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 PackDayHostHandoffKind { + RevealBundle, + OpenPackSheet, + OpenPickupRoster, + OpenCustomerLabels, +} + +impl PackDayHostHandoffKind { + pub const fn all_v1() -> [Self; 4] { + [ + Self::RevealBundle, + Self::OpenPackSheet, + Self::OpenPickupRoster, + Self::OpenCustomerLabels, + ] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::RevealBundle => "reveal_bundle", + Self::OpenPackSheet => "open_pack_sheet", + Self::OpenPickupRoster => "open_pickup_roster", + Self::OpenCustomerLabels => "open_customer_labels", + } + } + + pub const fn artifact_kind(self) -> Option<PackDayExportArtifactKind> { + match self { + Self::RevealBundle => None, + Self::OpenPackSheet => Some(PackDayExportArtifactKind::PackSheet), + Self::OpenPickupRoster => Some(PackDayExportArtifactKind::PickupRoster), + Self::OpenCustomerLabels => Some(PackDayExportArtifactKind::CustomerLabels), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayExportStatus { + #[default] + Idle, + Running, + Succeeded, + Failed, +} + +impl PackDayExportStatus { + 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, 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 { + NeedsAction, + Scheduled, + Packed, +} + +impl PackDayOutputOrderState { + pub const fn all_v1() -> [Self; 3] { + [Self::NeedsAction, Self::Scheduled, Self::Packed] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::NeedsAction => "needs_action", + Self::Scheduled => "scheduled", + Self::Packed => "packed", + } + } + + pub const fn from_order_status(status: OrderStatus) -> Option<Self> { + match status { + OrderStatus::NeedsAction => Some(Self::NeedsAction), + OrderStatus::Scheduled => Some(Self::Scheduled), + OrderStatus::Packed => Some(Self::Packed), + OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => None, + } + } +} + +impl From<PackDayOutputOrderState> for OrderStatus { + fn from(value: PackDayOutputOrderState) -> Self { + match value { + PackDayOutputOrderState::NeedsAction => Self::NeedsAction, + PackDayOutputOrderState::Scheduled => Self::Scheduled, + PackDayOutputOrderState::Packed => Self::Packed, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputQuantity { + pub value: u32, + pub unit_label: String, +} + +impl PackDayOutputQuantity { + pub fn new(value: u32, unit_label: impl Into<String>) -> Self { + Self { + value, + unit_label: unit_label.into(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputWindow { + pub fulfillment_window_id: FulfillmentWindowId, + pub farm_id: FarmId, + pub farm_display_name: String, + pub pickup_location_label: Option<String>, + pub starts_at: String, + pub ends_at: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputProductTotal { + pub title: String, + pub quantity: PackDayOutputQuantity, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputPackListEntry { + pub order_id: OrderId, + pub order_number: String, + pub customer_display_name: String, + pub order_state: PackDayOutputOrderState, + pub title: String, + pub quantity: PackDayOutputQuantity, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputCustomerOrder { + pub order_id: OrderId, + pub order_number: String, + pub customer_display_name: String, + pub order_state: PackDayOutputOrderState, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputSource { + pub fulfillment_window: PackDayOutputWindow, + pub totals_by_product: Vec<PackDayOutputProductTotal>, + pub pack_list: Vec<PackDayOutputPackListEntry>, + pub pickup_roster: Vec<PackDayOutputCustomerOrder>, +} + +impl PackDayOutputSource { + pub fn is_empty(&self) -> bool { + self.totals_by_product.is_empty() + && self.pack_list.is_empty() + && self.pickup_roster.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayExportArtifact { + pub kind: PackDayExportArtifactKind, + pub relative_path: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayExportBundle { + pub fulfillment_window_id: FulfillmentWindowId, + pub export_instance_id: PackDayExportInstanceId, + pub generated_at_utc: String, + pub bundle_directory: String, + pub artifacts: Vec<PackDayExportArtifact>, +} + +impl PackDayExportBundle { + pub fn artifact_count(&self) -> usize { + self.artifacts.len() + } + + pub fn includes_artifact(&self, kind: PackDayExportArtifactKind) -> bool { + self.artifacts.iter().any(|artifact| artifact.kind == kind) + } +} + +#[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 ReminderSurface { + Today, + Orders, + PackDay, +} + +impl ReminderSurface { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Today => "today", + Self::Orders => "orders", + Self::PackDay => "pack_day", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReminderKind { + FulfillmentWindow, + OrderAction, + MissedPickupRecovery, + RefundRecovery, + SyncImpact, +} + +impl ReminderKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::FulfillmentWindow => "fulfillment_window", + Self::OrderAction => "order_action", + Self::MissedPickupRecovery => "missed_pickup_recovery", + Self::RefundRecovery => "refund_recovery", + Self::SyncImpact => "sync_impact", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReminderUrgency { + Upcoming, + DueSoon, + Overdue, + Blocking, +} + +impl ReminderUrgency { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Upcoming => "upcoming", + Self::DueSoon => "due_soon", + Self::Overdue => "overdue", + Self::Blocking => "blocking", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReminderDeliveryState { + Scheduled, + Presented, + Acknowledged, + Resolved, +} + +impl ReminderDeliveryState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Scheduled => "scheduled", + Self::Presented => "presented", + Self::Acknowledged => "acknowledged", + Self::Resolved => "resolved", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecoveryKind { + MissedPickup, + RefundFollowUp, +} + +impl RecoveryKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::MissedPickup => "missed_pickup", + Self::RefundFollowUp => "refund_follow_up", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecoveryState { + Open, + InReview, + Resolved, +} + +impl RecoveryState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Open => "open", + Self::InReview => "in_review", + Self::Resolved => "resolved", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RepeatDemandEligibility { + Eligible, + Partial, + Unavailable, +} + +impl RepeatDemandEligibility { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Eligible => "eligible", + Self::Partial => "partial", + Self::Unavailable => "unavailable", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum AppActivityKind { + HomeOpened, + SettingsOpened { + section: SettingsSection, + }, + SettingsSectionSelected { + section: SettingsSection, + }, + SettingsPreferenceUpdated { + preference: SettingsPreference, + enabled: bool, + }, +} + +impl AppActivityKind { + pub const fn storage_key(&self) -> &'static str { + match self { + Self::HomeOpened => "home_opened", + Self::SettingsOpened { .. } => "settings_opened", + Self::SettingsSectionSelected { .. } => "settings_section_selected", + Self::SettingsPreferenceUpdated { .. } => "settings_preference_updated", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppActivityEvent { + pub activity_event_id: ActivityEventId, + pub recorded_at: String, + pub kind: AppActivityKind, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppActivityContext { + pub recent_events: Vec<AppActivityEvent>, +} + +impl AppActivityContext { + pub fn from_recent_events(recent_events: Vec<AppActivityEvent>) -> Self { + Self { recent_events } + } +} diff --git a/crates/view/Cargo.toml b/crates/view/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "radroots_app_view" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +radroots_app_types.workspace = true +serde.workspace = true +url = "2" + +[dev-dependencies] +uuid.workspace = true + +[lints] +workspace = true diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -0,0 +1,3214 @@ +#![forbid(unsafe_code)] + +pub use radroots_app_types::*; + +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeSet, error::Error, fmt, str::FromStr}; +use url::Url; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActiveSurface { + #[default] + Farmer, + Personal, +} + +impl ActiveSurface { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Farmer => "farmer", + Self::Personal => "personal", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmerSection { + #[default] + Today, + Products, + Orders, + PackDay, + Farm, +} + +impl FarmerSection { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Today => "farmer.today", + Self::Products => "farmer.products", + Self::Orders => "farmer.orders", + Self::PackDay => "farmer.pack_day", + Self::Farm => "farmer.farm", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PersonalSection { + #[default] + Browse, + Search, + Cart, + Orders, +} + +impl PersonalSection { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Browse => "personal.browse", + Self::Search => "personal.search", + Self::Cart => "personal.cart", + Self::Orders => "personal.orders", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "surface", content = "section", rename_all = "snake_case")] +pub enum ShellSection { + #[default] + Home, + Personal(PersonalSection), + Farmer(FarmerSection), + Settings(SettingsSection), +} + +impl ShellSection { + pub const fn surface(self) -> Option<ActiveSurface> { + match self { + Self::Home | Self::Settings(_) => None, + Self::Personal(_) => Some(ActiveSurface::Personal), + Self::Farmer(_) => Some(ActiveSurface::Farmer), + } + } + + pub const fn default_for_surface(surface: ActiveSurface) -> Self { + match surface { + ActiveSurface::Personal => Self::Personal(PersonalSection::Browse), + ActiveSurface::Farmer => Self::Farmer(FarmerSection::Today), + } + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::Home => "home", + Self::Personal(section) => section.storage_key(), + Self::Farmer(section) => section.storage_key(), + Self::Settings(section) => section.storage_key(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ParseShellSectionError; + +impl fmt::Display for ParseShellSectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("invalid shell section key") + } +} + +impl Error for ParseShellSectionError {} + +impl FromStr for ShellSection { + type Err = ParseShellSectionError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + match value { + "home" => Ok(Self::Home), + "personal.browse" => Ok(Self::Personal(PersonalSection::Browse)), + "personal.search" => Ok(Self::Personal(PersonalSection::Search)), + "personal.cart" => Ok(Self::Personal(PersonalSection::Cart)), + "personal.orders" => Ok(Self::Personal(PersonalSection::Orders)), + "farmer.today" => Ok(Self::Farmer(FarmerSection::Today)), + "farmer.products" => Ok(Self::Farmer(FarmerSection::Products)), + "farmer.orders" => Ok(Self::Farmer(FarmerSection::Orders)), + "farmer.pack_day" => Ok(Self::Farmer(FarmerSection::PackDay)), + "farmer.farm" => Ok(Self::Farmer(FarmerSection::Farm)), + "settings.account" => Ok(Self::Settings(SettingsSection::Account)), + "settings.farm" => Ok(Self::Settings(SettingsSection::Farm)), + "settings.settings" => Ok(Self::Settings(SettingsSection::Settings)), + "settings.about" => Ok(Self::Settings(SettingsSection::About)), + _ => Err(ParseShellSectionError), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityBlockedReason { + RuntimeUnavailable, + HostVaultUnavailable, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", content = "reason", rename_all = "snake_case")] +pub enum IdentityReadiness { + #[default] + MissingAccount, + Ready, + Blocked(IdentityBlockedReason), +} + +impl IdentityReadiness { + pub const fn storage_key(self) -> &'static str { + match self { + Self::MissingAccount => "missing_account", + Self::Ready => "ready", + Self::Blocked(IdentityBlockedReason::RuntimeUnavailable) => "runtime_unavailable", + Self::Blocked(IdentityBlockedReason::HostVaultUnavailable) => "host_vault_unavailable", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SelectedSurfaceProjection { + pub active_surface: ActiveSurface, +} + +impl Default for SelectedSurfaceProjection { + fn default() -> Self { + Self::new(ActiveSurface::Personal) + } +} + +impl SelectedSurfaceProjection { + pub const fn new(active_surface: ActiveSurface) -> Self { + Self { active_surface } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmerActivationProjection { + pub farm_id: Option<FarmId>, +} + +impl FarmerActivationProjection { + pub const fn inactive() -> Self { + Self { farm_id: None } + } + + pub fn active(farm_id: FarmId) -> Self { + Self { + farm_id: Some(farm_id), + } + } + + pub const fn is_active(&self) -> bool { + self.farm_id.is_some() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AccountSummary { + pub account_id: String, + pub npub: String, + pub label: Option<String>, + pub custody: AccountCustody, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AccountSurfaceActivationProjection { + pub account_id: String, + pub selected_surface: SelectedSurfaceProjection, + pub farmer_activation: FarmerActivationProjection, +} + +impl AccountSurfaceActivationProjection { + pub fn new( + account_id: impl Into<String>, + selected_surface: SelectedSurfaceProjection, + farmer_activation: FarmerActivationProjection, + ) -> Self { + let active_surface = if farmer_activation.is_active() { + selected_surface.active_surface + } else { + ActiveSurface::Personal + }; + + Self { + account_id: account_id.into(), + selected_surface: SelectedSurfaceProjection::new(active_surface), + farmer_activation, + } + } + + pub const fn active_surface(&self) -> ActiveSurface { + self.selected_surface.active_surface + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SelectedAccountProjection { + pub account: AccountSummary, + pub selected_surface: SelectedSurfaceProjection, + pub farmer_activation: FarmerActivationProjection, +} + +impl SelectedAccountProjection { + pub fn new( + account: AccountSummary, + selected_surface: SelectedSurfaceProjection, + farmer_activation: FarmerActivationProjection, + ) -> Self { + let active_surface = if farmer_activation.is_active() { + selected_surface.active_surface + } else { + ActiveSurface::Personal + }; + + Self { + account, + selected_surface: SelectedSurfaceProjection::new(active_surface), + farmer_activation, + } + } + + pub fn from_surface_activation( + account: AccountSummary, + activation: AccountSurfaceActivationProjection, + ) -> Self { + Self::new( + account, + activation.selected_surface, + activation.farmer_activation, + ) + } + + pub const fn active_surface(&self) -> ActiveSurface { + self.selected_surface.active_surface + } +} + +impl From<&SelectedAccountProjection> for AccountSurfaceActivationProjection { + fn from(value: &SelectedAccountProjection) -> Self { + Self::new( + value.account.account_id.clone(), + value.selected_surface, + value.farmer_activation.clone(), + ) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppStartupGate { + Blocked, + #[default] + SetupRequired, + Personal, + Farmer, +} + +impl AppStartupGate { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Blocked => "blocked", + Self::SetupRequired => "setup_required", + Self::Personal => "personal", + Self::Farmer => "farmer", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoggedOutStartupPhase { + #[default] + ContinuePrompt, + IdentityChoice, + GenerateKeyStarting, + SignerEntry, +} + +impl LoggedOutStartupPhase { + pub const fn storage_key(self) -> &'static str { + match self { + Self::ContinuePrompt => "continue_prompt", + Self::IdentityChoice => "identity_choice", + Self::GenerateKeyStarting => "generate_key_starting", + Self::SignerEntry => "signer_entry", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StartupSignerSourceKind { + BunkerUri, + DiscoveryUrl, +} + +impl StartupSignerSourceKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::BunkerUri => "bunker_uri", + Self::DiscoveryUrl => "discovery_url", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ParseStartupSignerSourceError { + EmptyInput, + UnsupportedClientUri, + UnsupportedSource, + MissingDiscoveryUri, +} + +impl fmt::Display for ParseStartupSignerSourceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyInput => formatter.write_str("signer source input must not be empty"), + Self::UnsupportedClientUri => formatter.write_str( + "client nostrconnect URIs are not accepted by the app signer entry flow", + ), + Self::UnsupportedSource => { + formatter.write_str("signer source input must be a bunker URI or discovery URL") + } + Self::MissingDiscoveryUri => { + formatter.write_str("discovery URL must include a non-empty uri query parameter") + } + } + } +} + +impl Error for ParseStartupSignerSourceError {} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum StartupSignerSource { + BunkerUri(String), + DiscoveryUrl(String), +} + +impl StartupSignerSource { + pub const fn kind(&self) -> StartupSignerSourceKind { + match self { + Self::BunkerUri(_) => StartupSignerSourceKind::BunkerUri, + Self::DiscoveryUrl(_) => StartupSignerSourceKind::DiscoveryUrl, + } + } + + pub fn value(&self) -> &str { + match self { + Self::BunkerUri(value) | Self::DiscoveryUrl(value) => value, + } + } +} + +impl FromStr for StartupSignerSource { + type Err = ParseStartupSignerSourceError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(ParseStartupSignerSourceError::EmptyInput); + } + + if trimmed.starts_with("nostrconnect://") { + return Err(ParseStartupSignerSourceError::UnsupportedClientUri); + } + + if trimmed.starts_with("bunker://") { + return Ok(Self::BunkerUri(trimmed.to_owned())); + } + + let url = + Url::parse(trimmed).map_err(|_| ParseStartupSignerSourceError::UnsupportedSource)?; + let has_discovery_uri = url + .query_pairs() + .any(|(key, value)| key == "uri" && !value.trim().is_empty()); + + if !has_discovery_uri { + return Err(ParseStartupSignerSourceError::MissingDiscoveryUri); + } + + Ok(Self::DiscoveryUrl(trimmed.to_owned())) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct StartupSignerEntryProjection { + pub source_input: String, +} + +impl StartupSignerEntryProjection { + pub fn new(source_input: impl Into<String>) -> Self { + Self { + source_input: source_input.into(), + } + } + + pub fn parsed_source(&self) -> Result<StartupSignerSource, ParseStartupSignerSourceError> { + self.source_input.parse() + } + + pub fn set_source_input(&mut self, source_input: impl Into<String>) { + self.source_input = source_input.into(); + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct LoggedOutStartupProjection { + pub phase: LoggedOutStartupPhase, + pub signer_entry: StartupSignerEntryProjection, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "account_id", rename_all = "snake_case")] +pub enum BuyerContext { + #[default] + Guest, + Account(String), +} + +impl BuyerContext { + pub const fn guest() -> Self { + Self::Guest + } + + pub fn account(account_id: impl Into<String>) -> Self { + Self::Account(account_id.into()) + } + + pub fn storage_key(&self) -> String { + match self { + Self::Guest => "guest".to_owned(), + Self::Account(account_id) => format!("account:{account_id}"), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PersonalEntryState { + Blocked, + #[default] + Guest, + SignedIn, +} + +impl PersonalEntryState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Blocked => "blocked", + Self::Guest => "guest", + Self::SignedIn => "signed_in", + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PersonalEntryProjection { + pub state: PersonalEntryState, + pub selected_account: Option<SelectedAccountProjection>, + pub can_enter_farmer_workspace: bool, +} + +impl PersonalEntryProjection { + pub fn blocked(selected_account: Option<SelectedAccountProjection>) -> Self { + let can_enter_farmer_workspace = selected_account + .as_ref() + .is_some_and(|account| account.farmer_activation.is_active()); + + Self { + state: PersonalEntryState::Blocked, + selected_account, + can_enter_farmer_workspace, + } + } + + pub const fn guest() -> Self { + Self { + state: PersonalEntryState::Guest, + selected_account: None, + can_enter_farmer_workspace: false, + } + } + + pub fn signed_in(selected_account: SelectedAccountProjection) -> Self { + let can_enter_farmer_workspace = selected_account.farmer_activation.is_active(); + + Self { + state: PersonalEntryState::SignedIn, + selected_account: Some(selected_account), + can_enter_farmer_workspace, + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppIdentityProjection { + pub readiness: IdentityReadiness, + pub roster: Vec<AccountSummary>, + pub selected_account: Option<SelectedAccountProjection>, +} + +impl AppIdentityProjection { + pub fn missing() -> Self { + Self::with_readiness(IdentityReadiness::MissingAccount, Vec::new(), None) + } + + pub fn missing_with_roster(roster: Vec<AccountSummary>) -> Self { + Self::with_readiness(IdentityReadiness::MissingAccount, roster, None) + } + + pub fn blocked(reason: IdentityBlockedReason) -> Self { + Self::with_readiness(IdentityReadiness::Blocked(reason), Vec::new(), None) + } + + pub fn blocked_with_selection( + reason: IdentityBlockedReason, + roster: Vec<AccountSummary>, + selected_account: Option<SelectedAccountProjection>, + ) -> Self { + Self::with_readiness(IdentityReadiness::Blocked(reason), roster, selected_account) + } + + pub fn ready(roster: Vec<AccountSummary>, selected_account: SelectedAccountProjection) -> Self { + Self::with_readiness(IdentityReadiness::Ready, roster, Some(selected_account)) + } + + pub fn with_readiness( + readiness: IdentityReadiness, + mut roster: Vec<AccountSummary>, + selected_account: Option<SelectedAccountProjection>, + ) -> Self { + if let Some(selected_account) = selected_account.as_ref() + && !roster + .iter() + .any(|account| account.account_id == selected_account.account.account_id) + { + roster.insert(0, selected_account.account.clone()); + } + + Self { + readiness, + roster, + selected_account, + } + } + + pub fn startup_gate(&self) -> AppStartupGate { + match self.readiness { + IdentityReadiness::MissingAccount => AppStartupGate::SetupRequired, + IdentityReadiness::Blocked(_) => AppStartupGate::Blocked, + IdentityReadiness::Ready => self + .selected_account + .as_ref() + .map(|account| { + if account.farmer_activation.is_active() + && account.active_surface() == ActiveSurface::Farmer + { + AppStartupGate::Farmer + } else { + AppStartupGate::Personal + } + }) + .unwrap_or(AppStartupGate::SetupRequired), + } + } + + pub fn settings_account(&self) -> SettingsAccountProjection { + self.into() + } + + pub fn personal_entry(&self) -> PersonalEntryProjection { + match self.readiness { + IdentityReadiness::MissingAccount => PersonalEntryProjection::guest(), + IdentityReadiness::Blocked(_) => { + PersonalEntryProjection::blocked(self.selected_account.clone()) + } + IdentityReadiness::Ready => self + .selected_account + .clone() + .map(PersonalEntryProjection::signed_in) + .unwrap_or_else(PersonalEntryProjection::guest), + } + } + + pub fn buyer_context(&self) -> BuyerContext { + self.selected_account + .as_ref() + .map(|account| BuyerContext::account(account.account.account_id.clone())) + .unwrap_or_default() + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct SettingsAccountProjection { + pub readiness: IdentityReadiness, + pub roster: Vec<AccountSummary>, + pub selected_account: Option<SelectedAccountProjection>, +} + +impl From<&AppIdentityProjection> for SettingsAccountProjection { + fn from(value: &AppIdentityProjection) -> Self { + Self { + readiness: value.readiness, + roster: value.roster.clone(), + selected_account: value.selected_account.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmRulesProjection { + pub farm_profile: Option<FarmProfileRecord>, + pub pickup_locations: Vec<PickupLocationRecord>, + pub operating_rules: Option<FarmOperatingRulesRecord>, + pub fulfillment_windows: Vec<FulfillmentWindowRecord>, + pub blackout_periods: Vec<BlackoutPeriodRecord>, + pub readiness: FarmRulesReadiness, +} + +impl Default for FarmRulesProjection { + fn default() -> Self { + Self { + farm_profile: None, + pickup_locations: Vec::new(), + operating_rules: None, + fulfillment_windows: Vec::new(), + blackout_periods: Vec::new(), + readiness: FarmRulesReadiness::missing_v1_basics(), + } + } +} + +impl FarmRulesProjection { + pub fn is_ready(&self) -> bool { + self.readiness.is_ready() + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductsFilter { + #[default] + All, + Live, + Drafts, + NeedAttention, + Paused, + Archived, +} + +impl ProductsFilter { + pub const fn storage_key(self) -> &'static str { + match self { + Self::All => "all", + Self::Live => "live", + Self::Drafts => "drafts", + Self::NeedAttention => "need_attention", + Self::Paused => "paused", + Self::Archived => "archived", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductsSort { + #[default] + Updated, + Name, + Availability, + Stock, + Price, +} + +impl ProductsSort { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Updated => "updated", + Self::Name => "name", + Self::Availability => "availability", + Self::Stock => "stock", + Self::Price => "price", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductAttentionState { + #[default] + Healthy, + LowStock, + SoldOut, + MissingAvailability, + NoFutureAvailability, + MissingDetails, +} + +impl ProductAttentionState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Healthy => "healthy", + Self::LowStock => "low_stock", + Self::SoldOut => "sold_out", + Self::MissingAvailability => "missing_availability", + Self::NoFutureAvailability => "no_future_availability", + Self::MissingDetails => "missing_details", + } + } + + pub const fn requires_attention(self) -> bool { + !matches!(self, Self::Healthy) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductAvailabilityState { + Scheduled, + Open, + MissingWindow, + NoFutureWindow, +} + +impl ProductAvailabilityState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Scheduled => "scheduled", + Self::Open => "open", + Self::MissingWindow => "missing_window", + Self::NoFutureWindow => "no_future_window", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductAvailabilitySummary { + pub state: ProductAvailabilityState, + pub label: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductStockState { + Unset, + InStock, + LowStock, + SoldOut, +} + +impl ProductStockState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Unset => "unset", + Self::InStock => "in_stock", + Self::LowStock => "low_stock", + Self::SoldOut => "sold_out", + } + } + + pub const fn requires_attention(self) -> bool { + matches!(self, Self::LowStock | Self::SoldOut) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductStockSummary { + pub quantity: Option<u32>, + pub unit_label: Option<String>, + pub state: ProductStockState, +} + +impl ProductStockSummary { + pub const fn requires_attention(&self) -> bool { + self.state.requires_attention() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductPricePresentation { + pub amount_minor_units: u32, + pub currency_code: String, + pub unit_label: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductsListSummary { + pub total_products: u32, + pub live_products: u32, + pub draft_products: u32, + pub need_attention_products: u32, +} + +impl ProductsListSummary { + pub const fn has_products(&self) -> bool { + self.total_products > 0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductsListRow { + pub product_id: ProductId, + pub farm_id: FarmId, + pub title: String, + pub subtitle: Option<String>, + pub status: ProductStatus, + pub attention_state: ProductAttentionState, + pub availability: ProductAvailabilitySummary, + pub stock: ProductStockSummary, + pub price: Option<ProductPricePresentation>, + pub updated_at: String, +} + +impl ProductsListRow { + pub const fn requires_attention(&self) -> bool { + self.attention_state.requires_attention() || self.stock.requires_attention() + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductsListProjection { + pub summary: ProductsListSummary, + pub rows: Vec<ProductsListRow>, +} + +impl ProductsListProjection { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductEditorDraft { + pub title: String, + pub subtitle: String, + pub category: String, + pub unit_label: String, + pub price_minor_units: Option<u32>, + pub price_currency: String, + pub stock_quantity: Option<u32>, + pub availability_window_id: Option<FulfillmentWindowId>, + pub status: ProductStatus, +} + +impl Default for ProductEditorDraft { + fn default() -> Self { + Self { + title: String::new(), + subtitle: String::new(), + category: String::new(), + unit_label: String::new(), + price_minor_units: None, + price_currency: "USD".to_owned(), + stock_quantity: None, + availability_window_id: None, + status: ProductStatus::Draft, + } + } +} + +impl ProductEditorDraft { + pub fn publish_blockers(&self) -> Vec<ProductPublishBlocker> { + let mut blockers = Vec::new(); + + if self.title.trim().is_empty() { + blockers.push(ProductPublishBlocker::AddProductName); + } + + if self.category.trim().is_empty() { + blockers.push(ProductPublishBlocker::ChooseCategory); + } + + if self.unit_label.trim().is_empty() { + blockers.push(ProductPublishBlocker::ChooseUnit); + } + + if self.price_minor_units.is_none_or(|value| value == 0) { + blockers.push(ProductPublishBlocker::SetPrice); + } + + if self.stock_quantity.is_none() { + blockers.push(ProductPublishBlocker::SetStock); + } + + if self.availability_window_id.is_none() { + blockers.push(ProductPublishBlocker::AttachAvailability); + } + + blockers + } + + pub fn is_publish_ready(&self) -> bool { + self.publish_blockers().is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerListingRow { + pub product_id: ProductId, + pub farm_id: FarmId, + pub farm_display_name: String, + pub listing_relays: Vec<String>, + pub title: String, + pub subtitle: Option<String>, + pub price: ProductPricePresentation, + pub availability: ProductAvailabilitySummary, + pub stock: ProductStockSummary, + pub fulfillment_methods: BTreeSet<FarmOrderMethod>, + pub next_fulfillment_window_label: Option<String>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerListingsProjection { + pub rows: Vec<BuyerListingRow>, +} + +impl BuyerListingsProjection { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerProductDetailProjection { + pub listing: BuyerListingRow, + pub detail_text: Option<String>, + pub selected_quantity: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCartLineProjection { + pub product_id: ProductId, + pub farm_id: FarmId, + pub farm_display_name: String, + pub title: String, + pub quantity: u32, + pub unit_price: ProductPricePresentation, + pub line_total_minor_units: u32, + pub fulfillment_summary: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCartReplaceConfirmationProjection { + pub current_farm_display_name: String, + pub incoming_farm_display_name: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCartProjection { + pub farm_id: Option<FarmId>, + pub farm_display_name: Option<String>, + pub lines: Vec<BuyerCartLineProjection>, + pub subtotal_minor_units: Option<u32>, + pub currency_code: Option<String>, + pub replace_confirmation: Option<BuyerCartReplaceConfirmationProjection>, +} + +impl BuyerCartProjection { + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCheckoutDraft { + pub name: String, + pub email: String, + pub phone: String, + pub order_note: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCheckoutSummaryProjection { + pub farm_display_name: Option<String>, + pub fulfillment_summary: Option<String>, + pub line_count: u32, + pub subtotal_minor_units: Option<u32>, + pub currency_code: Option<String>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BuyerCheckoutDisabledReason { + EmptyCart, + MissingFulfillment, + MissingName, + MissingEmail, + AccountRequired, +} + +impl BuyerCheckoutDisabledReason { + pub const fn storage_key(self) -> &'static str { + match self { + Self::EmptyCart => "empty_cart", + Self::MissingFulfillment => "missing_fulfillment", + Self::MissingName => "missing_name", + Self::MissingEmail => "missing_email", + Self::AccountRequired => "account_required", + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerCheckoutProjection { + pub draft: BuyerCheckoutDraft, + pub summary: BuyerCheckoutSummaryProjection, + pub can_place_order: bool, + pub place_order_disabled_reason: Option<BuyerCheckoutDisabledReason>, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrdersFilter { + All, + #[default] + NeedsAction, + Scheduled, + Packed, + Completed, + Refunded, +} + +impl OrdersFilter { + pub const fn storage_key(self) -> &'static str { + match self { + Self::All => "all", + Self::NeedsAction => "needs_action", + Self::Scheduled => "scheduled", + Self::Packed => "packed", + Self::Completed => "completed", + Self::Refunded => "refunded", + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrdersScreenQueryState { + pub filter: OrdersFilter, + pub fulfillment_window_id: Option<FulfillmentWindowId>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrderPrimaryAction { + Review, + MarkPacked, + MarkCompleted, +} + +impl OrderPrimaryAction { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Review => "review", + Self::MarkPacked => "mark_packed", + Self::MarkCompleted => "mark_completed", + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrdersListSummary { + pub total_orders: u32, + pub needs_action_orders: u32, + pub scheduled_orders: u32, + pub packed_orders: u32, +} + +impl OrdersListSummary { + pub const fn has_orders(&self) -> bool { + self.total_orders > 0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrdersListRow { + pub order_id: OrderId, + pub farm_id: FarmId, + pub fulfillment_window_id: Option<FulfillmentWindowId>, + pub order_number: String, + pub customer_display_name: String, + pub fulfillment_window_label: Option<String>, + pub pickup_location_label: Option<String>, + pub status: OrderStatus, + pub primary_action: Option<OrderPrimaryAction>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrdersListProjection { + pub summary: OrdersListSummary, + pub rows: Vec<OrdersListRow>, +} + +impl OrdersListProjection { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrderDetailItemRow { + pub title: String, + pub quantity_display: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrderDetailProjection { + pub order_id: OrderId, + pub farm_id: FarmId, + pub order_number: String, + pub customer_display_name: String, + pub status: OrderStatus, + pub fulfillment_window_id: Option<FulfillmentWindowId>, + pub fulfillment_window_label: Option<String>, + pub pickup_location_label: Option<String>, + pub items: Vec<OrderDetailItemRow>, + pub primary_action: Option<OrderPrimaryAction>, + pub recoveries: Vec<OrderRecoveryProjection>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerOrdersListRow { + pub order_id: OrderId, + pub farm_id: FarmId, + pub order_number: String, + pub farm_display_name: String, + pub fulfillment_summary: String, + pub status: BuyerOrderStatus, + pub repeat_demand: Option<RepeatDemandHandoffProjection>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerOrdersProjection { + pub rows: Vec<BuyerOrdersListRow>, +} + +impl BuyerOrdersProjection { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BuyerOrderDetailProjection { + pub order_id: OrderId, + pub farm_id: FarmId, + pub order_number: String, + pub farm_display_name: String, + pub fulfillment_summary: String, + pub status: BuyerOrderStatus, + pub items: Vec<OrderDetailItemRow>, + pub order_note: Option<String>, + pub repeat_demand: Option<RepeatDemandHandoffProjection>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayScreenQueryState { + pub fulfillment_window_id: Option<FulfillmentWindowId>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayProductTotalRow { + pub title: String, + pub quantity_display: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayPackListRow { + pub title: String, + pub quantity_display: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayRosterRow { + pub order_id: OrderId, + pub order_number: String, + pub customer_display_name: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayProjection { + pub fulfillment_window: Option<FulfillmentWindowSummary>, + pub reminders: ReminderFeedProjection, + pub totals_by_product: Vec<PackDayProductTotalRow>, + pub pack_list: Vec<PackDayPackListRow>, + pub pickup_roster: Vec<PackDayRosterRow>, +} + +impl PackDayProjection { + pub fn is_empty(&self) -> bool { + self.totals_by_product.is_empty() + && self.pack_list.is_empty() + && self.pickup_roster.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmSummary { + pub farm_id: FarmId, + pub display_name: String, + 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, 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, + pub farm_id: FarmId, + pub starts_at: String, + pub ends_at: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TodaySummary { + pub farm_id: FarmId, + pub orders_needing_action: u32, + pub low_stock_products: u32, + pub draft_products: u32, + pub reminders_due_soon: u32, + pub recovery_actions_open: u32, +} + +impl TodaySummary { + pub const fn has_attention_items(&self) -> bool { + self.orders_needing_action > 0 + || self.low_stock_products > 0 + || self.draft_products > 0 + || self.reminders_due_soon > 0 + || self.recovery_actions_open > 0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ReminderDeadlineProjection { + pub reminder_id: ReminderId, + pub farm_id: FarmId, + pub order_id: Option<OrderId>, + pub fulfillment_window_id: Option<FulfillmentWindowId>, + pub kind: ReminderKind, + pub surface: ReminderSurface, + pub urgency: ReminderUrgency, + pub title: String, + pub detail: String, + pub deadline_at: String, + pub action_label: Option<String>, + pub delivery_state: ReminderDeliveryState, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ReminderFeedProjection { + pub items: Vec<ReminderDeadlineProjection>, +} + +impl ReminderFeedProjection { + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn due_soon_count(&self) -> usize { + self.items + .iter() + .filter(|item| { + matches!( + item.urgency, + ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking + ) + }) + .count() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ReminderLogEntryProjection { + pub reminder_id: ReminderId, + pub kind: ReminderKind, + pub title: String, + pub recorded_at: String, + pub delivery_state: ReminderDeliveryState, + pub detail: Option<String>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ReminderLogProjection { + pub entries: Vec<ReminderLogEntryProjection>, +} + +impl ReminderLogProjection { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrderRecoveryProjection { + pub recovery_record_id: RecoveryRecordId, + pub order_id: OrderId, + pub kind: RecoveryKind, + pub state: RecoveryState, + pub summary: String, + pub note: Option<String>, + pub last_updated_at: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct RecoveryQueueProjection { + pub items: Vec<OrderRecoveryProjection>, +} + +impl RecoveryQueueProjection { + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct RepeatDemandHandoffProjection { + pub order_id: OrderId, + pub farm_id: FarmId, + pub eligibility: RepeatDemandEligibility, + pub available_item_count: u32, + pub unavailable_item_count: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductListRow { + pub product_id: ProductId, + pub farm_id: FarmId, + pub title: String, + pub status: ProductStatus, + pub stock_count: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrderListRow { + pub order_id: OrderId, + pub farm_id: FarmId, + pub fulfillment_window_id: Option<FulfillmentWindowId>, + pub order_number: String, + pub customer_display_name: String, + pub status: OrderStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TodaySetupTaskKind { + CompleteFarmProfile, + AddPickupLocation, + AddOperatingRules, + AddFulfillmentWindow, + ResolveAvailabilityConflicts, + PublishProduct, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TodaySetupTask { + pub kind: TodaySetupTaskKind, + pub is_complete: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct TodayAgendaProjection { + pub farm: Option<FarmSummary>, + pub summary: Option<TodaySummary>, + pub reminders: ReminderFeedProjection, + pub orders_needing_action: Vec<OrderListRow>, + pub low_stock_products: Vec<ProductListRow>, + pub draft_products: Vec<ProductListRow>, + pub next_fulfillment_window: Option<FulfillmentWindowSummary>, + pub setup_checklist: Vec<TodaySetupTask>, +} + +impl TodayAgendaProjection { + pub fn has_attention_items(&self) -> bool { + self.summary + .as_ref() + .is_some_and(TodaySummary::has_attention_items) + || !self.reminders.is_empty() + || !self.orders_needing_action.is_empty() + || !self.low_stock_products.is_empty() + || !self.draft_products.is_empty() + } + + pub fn needs_setup(&self) -> bool { + self.setup_checklist.iter().any(|item| !item.is_complete) + } +} + +#[cfg(test)] +mod tests { + use super::{ + AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, + ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, + AppIdentityProjection, AppStartupGate, BlackoutPeriodId, BuyerCartLineProjection, + BuyerCartProjection, BuyerCheckoutDisabledReason, BuyerCheckoutDraft, + BuyerCheckoutProjection, BuyerCheckoutSummaryProjection, BuyerContext, BuyerListingRow, + BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrderStatus, BuyerOrdersListRow, + BuyerOrdersProjection, FarmId, FarmOrderMethod, FarmReadinessBlocker, FarmRulesProjection, + FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, + FarmSetupReadiness, FarmSetupSection, FarmTimingConflict, FarmTimingConflictKind, + FarmerActivationProjection, FarmerSection, FulfillmentWindowId, IdentityBlockedReason, + IdentityReadiness, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow, + OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, OrderRecoveryProjection, + OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, + OrdersScreenQueryState, PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, + PackDayBatchPrintStatus, PackDayExportArtifact, PackDayExportArtifactKind, + PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, + PackDayHostHandoffStatus, PackDayOutputCustomerOrder, PackDayOutputOrderState, + PackDayOutputPackListEntry, PackDayOutputProductTotal, PackDayOutputQuantity, + PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, PackDayPrintFailureKind, + PackDayPrintKind, PackDayPrintLabelStock, PackDayPrintStatus, 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, + }; + use std::{collections::BTreeSet, str::FromStr}; + use uuid::Uuid; + + #[test] + fn shell_section_storage_keys_are_unique_and_round_trip() { + let sections = [ + ShellSection::Home, + ShellSection::Personal(PersonalSection::Browse), + ShellSection::Personal(PersonalSection::Search), + ShellSection::Personal(PersonalSection::Cart), + ShellSection::Personal(PersonalSection::Orders), + ShellSection::Farmer(FarmerSection::Today), + ShellSection::Farmer(FarmerSection::Products), + ShellSection::Farmer(FarmerSection::Orders), + ShellSection::Farmer(FarmerSection::PackDay), + ShellSection::Farmer(FarmerSection::Farm), + ShellSection::Settings(SettingsSection::Account), + ShellSection::Settings(SettingsSection::Farm), + ShellSection::Settings(SettingsSection::Settings), + ShellSection::Settings(SettingsSection::About), + ]; + let keys = sections + .into_iter() + .map(ShellSection::storage_key) + .collect::<BTreeSet<_>>(); + + assert_eq!(keys.len(), sections.len()); + + for section in sections { + let parsed = + ShellSection::from_str(section.storage_key()).expect("section should parse"); + assert_eq!(parsed, section); + } + } + + #[test] + fn shell_section_surface_is_explicit_for_surface_routes_only() { + assert_eq!(ShellSection::Home.surface(), None); + assert_eq!( + ShellSection::Personal(PersonalSection::Browse).surface(), + Some(ActiveSurface::Personal) + ); + assert_eq!( + ShellSection::Farmer(FarmerSection::Today).surface(), + Some(ActiveSurface::Farmer) + ); + assert_eq!( + ShellSection::Settings(SettingsSection::Settings).surface(), + None + ); + } + + #[test] + fn shell_section_default_for_surface_preserves_current_farmer_entry() { + assert_eq!( + ShellSection::default_for_surface(ActiveSurface::Personal), + ShellSection::Personal(PersonalSection::Browse) + ); + assert_eq!( + ShellSection::default_for_surface(ActiveSurface::Farmer), + ShellSection::Farmer(FarmerSection::Today) + ); + } + + #[test] + fn selected_surface_defaults_to_personal() { + assert_eq!( + SelectedSurfaceProjection::default().active_surface, + ActiveSurface::Personal + ); + } + + #[test] + fn selected_account_without_farmer_activation_falls_back_to_personal_surface() { + let projection = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_01".to_owned(), + npub: "npub1example".to_owned(), + label: Some("North field".to_owned()), + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::inactive(), + ); + + assert_eq!(projection.active_surface(), ActiveSurface::Personal); + assert!(!projection.farmer_activation.is_active()); + } + + #[test] + fn account_surface_activation_projection_normalizes_to_personal_without_farm_binding() { + let projection = AccountSurfaceActivationProjection::new( + "acct_04", + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::inactive(), + ); + + assert_eq!(projection.account_id, "acct_04"); + assert_eq!(projection.active_surface(), ActiveSurface::Personal); + assert!(!projection.farmer_activation.is_active()); + } + + #[test] + fn selected_account_projection_round_trips_through_surface_activation_state() { + let selected_account = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_roundtrip".to_owned(), + npub: "npub1roundtrip".to_owned(), + label: Some("Roundtrip".to_owned()), + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::active(FarmId::new()), + ); + let activation = AccountSurfaceActivationProjection::from(&selected_account); + let restored = SelectedAccountProjection::from_surface_activation( + selected_account.account.clone(), + activation, + ); + + assert_eq!(restored, selected_account); + } + + #[test] + fn startup_gate_tracks_setup_personal_farmer_and_blocked_states() { + let farmer_identity = AppIdentityProjection::ready( + Vec::new(), + SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_02".to_owned(), + npub: "npub1farmer".to_owned(), + label: None, + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::active(FarmId::new()), + ), + ); + let personal_identity = AppIdentityProjection::ready( + Vec::new(), + SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_03".to_owned(), + npub: "npub1personal".to_owned(), + label: None, + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Personal), + FarmerActivationProjection::inactive(), + ), + ); + + assert_eq!( + AppIdentityProjection::missing().startup_gate(), + AppStartupGate::SetupRequired + ); + assert_eq!(personal_identity.startup_gate(), AppStartupGate::Personal); + assert_eq!(farmer_identity.startup_gate(), AppStartupGate::Farmer); + assert_eq!( + AppIdentityProjection::blocked(IdentityBlockedReason::HostVaultUnavailable) + .startup_gate(), + AppStartupGate::Blocked + ); + } + + #[test] + fn ready_identity_keeps_selected_account_visible_in_roster() { + let selected_account = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_selected".to_owned(), + npub: "npub1selected".to_owned(), + label: None, + custody: AccountCustody::RemoteSigner, + }, + SelectedSurfaceProjection::new(ActiveSurface::Personal), + FarmerActivationProjection::inactive(), + ); + let projection = AppIdentityProjection::ready(Vec::new(), selected_account.clone()); + + assert_eq!(projection.readiness.storage_key(), "ready"); + assert_eq!(projection.roster.len(), 1); + assert_eq!(projection.roster[0], selected_account.account); + assert_eq!(projection.selected_account, Some(selected_account)); + } + + #[test] + fn blocked_identity_keeps_selected_account_visible_in_roster() { + let selected_account = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_blocked".to_owned(), + npub: "npub1blocked".to_owned(), + label: Some("Blocked account".to_owned()), + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Personal), + FarmerActivationProjection::inactive(), + ); + let projection = AppIdentityProjection::blocked_with_selection( + IdentityBlockedReason::HostVaultUnavailable, + Vec::new(), + Some(selected_account.clone()), + ); + + assert_eq!( + projection.readiness, + IdentityReadiness::Blocked(IdentityBlockedReason::HostVaultUnavailable) + ); + assert_eq!(projection.roster, vec![selected_account.account.clone()]); + assert_eq!(projection.selected_account, Some(selected_account)); + assert_eq!(projection.startup_gate(), AppStartupGate::Blocked); + } + + #[test] + fn missing_identity_can_keep_roster_visible_without_selected_account() { + let roster = vec![AccountSummary { + account_id: "acct_waiting".to_owned(), + npub: "npub1waiting".to_owned(), + label: Some("Waiting".to_owned()), + custody: AccountCustody::LocalManaged, + }]; + let projection = AppIdentityProjection::missing_with_roster(roster.clone()); + + assert_eq!(projection.readiness, IdentityReadiness::MissingAccount); + assert_eq!(projection.roster, roster); + assert!(projection.selected_account.is_none()); + assert_eq!(projection.startup_gate(), AppStartupGate::SetupRequired); + } + + #[test] + fn personal_entry_projection_is_derived_from_identity_truth() { + let guest_identity = AppIdentityProjection::missing(); + let selected_account = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_farmer".to_owned(), + npub: "npub1farmer".to_owned(), + label: Some("Field stand".to_owned()), + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::active(FarmId::new()), + ); + let signed_in_identity = AppIdentityProjection::ready(Vec::new(), selected_account.clone()); + let blocked_identity = AppIdentityProjection::blocked_with_selection( + IdentityBlockedReason::HostVaultUnavailable, + Vec::new(), + Some(selected_account.clone()), + ); + + assert_eq!( + guest_identity.personal_entry(), + PersonalEntryProjection::guest() + ); + assert_eq!( + guest_identity.personal_entry().state.storage_key(), + PersonalEntryState::Guest.storage_key() + ); + assert_eq!( + signed_in_identity.personal_entry(), + PersonalEntryProjection::signed_in(selected_account.clone()) + ); + assert!( + signed_in_identity + .personal_entry() + .can_enter_farmer_workspace + ); + assert_eq!( + blocked_identity.personal_entry(), + PersonalEntryProjection::blocked(Some(selected_account)) + ); + } + + #[test] + fn buyer_context_defaults_to_guest_and_tracks_selected_account() { + let selected_account = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_buyer".to_owned(), + npub: "npub1buyer".to_owned(), + label: Some("Buyer".to_owned()), + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Personal), + FarmerActivationProjection::inactive(), + ); + let ready_identity = AppIdentityProjection::ready(Vec::new(), selected_account); + + assert_eq!(BuyerContext::guest().storage_key(), "guest"); + assert_eq!( + BuyerContext::account("acct_buyer").storage_key(), + "account:acct_buyer" + ); + assert_eq!( + AppIdentityProjection::missing().buyer_context(), + BuyerContext::Guest + ); + assert_eq!( + ready_identity.buyer_context(), + BuyerContext::account("acct_buyer") + ); + } + + #[test] + fn logged_out_startup_defaults_to_continue_prompt_with_empty_signer_entry() { + assert_eq!( + LoggedOutStartupProjection::default(), + LoggedOutStartupProjection { + phase: LoggedOutStartupPhase::ContinuePrompt, + signer_entry: StartupSignerEntryProjection::default(), + } + ); + } + + #[test] + fn logged_out_startup_phase_and_signer_source_kind_storage_keys_are_stable() { + assert_eq!( + LoggedOutStartupPhase::ContinuePrompt.storage_key(), + "continue_prompt" + ); + assert_eq!( + LoggedOutStartupPhase::IdentityChoice.storage_key(), + "identity_choice" + ); + assert_eq!( + LoggedOutStartupPhase::GenerateKeyStarting.storage_key(), + "generate_key_starting" + ); + assert_eq!( + LoggedOutStartupPhase::SignerEntry.storage_key(), + "signer_entry" + ); + assert_eq!( + StartupSignerSourceKind::BunkerUri.storage_key(), + "bunker_uri" + ); + assert_eq!( + StartupSignerSourceKind::DiscoveryUrl.storage_key(), + "discovery_url" + ); + } + + #[test] + fn startup_signer_source_parses_direct_bunker_uri_and_discovery_url() { + let bunker_uri = + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example&secret=test-secret"; + let discovery_url = + format!("https://signer.radroots.example/connect?uri={bunker_uri}&label=field"); + + let bunker_source = bunker_uri + .parse::<StartupSignerSource>() + .expect("bunker uri should parse"); + let discovery_source = discovery_url + .parse::<StartupSignerSource>() + .expect("discovery url should parse"); + + assert_eq!( + bunker_source, + StartupSignerSource::BunkerUri(bunker_uri.to_owned()) + ); + assert_eq!(bunker_source.kind(), StartupSignerSourceKind::BunkerUri); + assert_eq!(bunker_source.value(), bunker_uri); + assert_eq!( + discovery_source, + StartupSignerSource::DiscoveryUrl(discovery_url.clone()) + ); + assert_eq!( + discovery_source.kind(), + StartupSignerSourceKind::DiscoveryUrl + ); + assert_eq!(discovery_source.value(), discovery_url); + } + + #[test] + fn startup_signer_source_rejects_empty_client_uri_and_missing_discovery_uri() { + assert_eq!( + "".parse::<StartupSignerSource>(), + Err(ParseStartupSignerSourceError::EmptyInput) + ); + assert_eq!( + "nostrconnect://npub1client?relay=wss%3A%2F%2Frelay.radroots.example&secret=test" + .parse::<StartupSignerSource>(), + Err(ParseStartupSignerSourceError::UnsupportedClientUri) + ); + assert_eq!( + "https://signer.radroots.example/connect".parse::<StartupSignerSource>(), + Err(ParseStartupSignerSourceError::MissingDiscoveryUri) + ); + assert_eq!( + "not a signer source".parse::<StartupSignerSource>(), + Err(ParseStartupSignerSourceError::UnsupportedSource) + ); + } + + #[test] + fn signer_entry_projection_exposes_the_typed_source_contract() { + let mut projection = StartupSignerEntryProjection::new( + " bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example ", + ); + + assert_eq!( + projection.parsed_source(), + Ok(StartupSignerSource::BunkerUri( + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example".to_owned() + )) + ); + + projection.set_source_input("https://signer.radroots.example/connect?uri=bunker://npub1"); + assert_eq!( + projection.parsed_source(), + Ok(StartupSignerSource::DiscoveryUrl( + "https://signer.radroots.example/connect?uri=bunker://npub1".to_owned() + )) + ); + } + + #[test] + fn typed_ids_round_trip_through_strings() { + let uuid = Uuid::parse_str("018f4d61-19b0-7cc4-9d4e-6d0df7c0aa11") + .expect("test uuid should parse"); + let farm_id = FarmId::from(uuid); + let parsed = FarmId::from_str(&farm_id.to_string()).expect("farm id should parse"); + + assert_eq!(parsed, farm_id); + assert_eq!(parsed.as_uuid(), uuid); + } + + #[test] + fn product_status_filter_and_sort_storage_keys_are_stable() { + assert_eq!(ProductStatus::Draft.storage_key(), "draft"); + assert_eq!(ProductStatus::Published.storage_key(), "published"); + assert_eq!(ProductStatus::Paused.storage_key(), "paused"); + assert_eq!(ProductStatus::Archived.storage_key(), "archived"); + assert!(ProductStatus::Published.is_live()); + assert!(!ProductStatus::Draft.is_live()); + + assert_eq!(ProductsFilter::default(), ProductsFilter::All); + assert_eq!(ProductsFilter::All.storage_key(), "all"); + assert_eq!(ProductsFilter::Live.storage_key(), "live"); + assert_eq!(ProductsFilter::Drafts.storage_key(), "drafts"); + assert_eq!( + ProductsFilter::NeedAttention.storage_key(), + "need_attention" + ); + assert_eq!(ProductsFilter::Paused.storage_key(), "paused"); + assert_eq!(ProductsFilter::Archived.storage_key(), "archived"); + + assert_eq!(ProductsSort::default(), ProductsSort::Updated); + assert_eq!(ProductsSort::Updated.storage_key(), "updated"); + assert_eq!(ProductsSort::Name.storage_key(), "name"); + assert_eq!(ProductsSort::Availability.storage_key(), "availability"); + assert_eq!(ProductsSort::Stock.storage_key(), "stock"); + assert_eq!(ProductsSort::Price.storage_key(), "price"); + } + + #[test] + fn buyer_checkout_disabled_reason_storage_keys_are_stable() { + assert_eq!( + BuyerCheckoutDisabledReason::EmptyCart.storage_key(), + "empty_cart" + ); + assert_eq!( + BuyerCheckoutDisabledReason::MissingFulfillment.storage_key(), + "missing_fulfillment" + ); + assert_eq!( + BuyerCheckoutDisabledReason::MissingName.storage_key(), + "missing_name" + ); + assert_eq!( + BuyerCheckoutDisabledReason::MissingEmail.storage_key(), + "missing_email" + ); + assert_eq!( + BuyerCheckoutDisabledReason::AccountRequired.storage_key(), + "account_required" + ); + } + + #[test] + fn product_attention_stock_and_projection_states_are_explicit() { + let row = ProductsListRow { + product_id: super::ProductId::new(), + farm_id: FarmId::new(), + title: "Pea shoots".to_owned(), + subtitle: Some("Tray-grown".to_owned()), + status: ProductStatus::Draft, + attention_state: ProductAttentionState::MissingAvailability, + availability: ProductAvailabilitySummary { + state: ProductAvailabilityState::MissingWindow, + label: "Missing window".to_owned(), + }, + stock: ProductStockSummary { + quantity: None, + unit_label: None, + state: ProductStockState::Unset, + }, + price: Some(ProductPricePresentation { + amount_minor_units: 300, + currency_code: "USD".to_owned(), + unit_label: "bag".to_owned(), + }), + updated_at: "2026-04-18T10:00:00Z".to_owned(), + }; + let summary = ProductsListSummary { + total_products: 1, + live_products: 0, + draft_products: 1, + need_attention_products: 1, + }; + let projection = ProductsListProjection { + summary: summary.clone(), + rows: vec![row.clone()], + }; + + assert_eq!(ProductAttentionState::LowStock.storage_key(), "low_stock"); + assert!(ProductAttentionState::LowStock.requires_attention()); + assert!(!ProductAttentionState::Healthy.requires_attention()); + assert_eq!( + ProductAvailabilityState::MissingWindow.storage_key(), + "missing_window" + ); + assert_eq!(ProductStockState::SoldOut.storage_key(), "sold_out"); + assert!(ProductStockState::SoldOut.requires_attention()); + assert!(!ProductStockState::InStock.requires_attention()); + assert!(row.requires_attention()); + assert!(summary.has_products()); + assert!(!projection.is_empty()); + assert_eq!(projection.rows[0].availability.label, "Missing window"); + } + + #[test] + fn product_editor_publish_blockers_are_explicit_and_minimal() { + let empty_draft = ProductEditorDraft::default(); + let ready_draft = ProductEditorDraft { + title: "Heirloom tomatoes".to_owned(), + subtitle: "Brandywine".to_owned(), + category: "vegetables".to_owned(), + unit_label: "lb".to_owned(), + price_minor_units: Some(450), + price_currency: "USD".to_owned(), + stock_quantity: Some(12), + availability_window_id: Some(super::FulfillmentWindowId::new()), + status: ProductStatus::Draft, + }; + + assert_eq!( + empty_draft.publish_blockers(), + vec![ + ProductPublishBlocker::AddProductName, + ProductPublishBlocker::ChooseCategory, + ProductPublishBlocker::ChooseUnit, + ProductPublishBlocker::SetPrice, + ProductPublishBlocker::SetStock, + ProductPublishBlocker::AttachAvailability, + ] + ); + assert_eq!( + ProductPublishBlocker::AttachAvailability.storage_key(), + "attach_availability" + ); + assert_eq!(empty_draft.price_currency, "USD"); + assert!(!empty_draft.is_publish_ready()); + assert!(ready_draft.is_publish_ready()); + assert!(ready_draft.publish_blockers().is_empty()); + } + + #[test] + fn order_status_filter_and_primary_action_storage_keys_are_stable() { + assert_eq!(OrderStatus::NeedsAction.storage_key(), "needs_action"); + assert_eq!(OrderStatus::Scheduled.storage_key(), "scheduled"); + assert_eq!(OrderStatus::Packed.storage_key(), "packed"); + assert_eq!(OrderStatus::Completed.storage_key(), "completed"); + assert_eq!(OrderStatus::Declined.storage_key(), "declined"); + assert_eq!(OrderStatus::Refunded.storage_key(), "refunded"); + assert_eq!(BuyerOrderStatus::Placed.storage_key(), "placed"); + assert_eq!(BuyerOrderStatus::Scheduled.storage_key(), "scheduled"); + assert_eq!(BuyerOrderStatus::Ready.storage_key(), "ready"); + assert_eq!(BuyerOrderStatus::Completed.storage_key(), "completed"); + assert_eq!(BuyerOrderStatus::Declined.storage_key(), "declined"); + assert_eq!(BuyerOrderStatus::Refunded.storage_key(), "refunded"); + assert_eq!( + BuyerOrderStatus::from(OrderStatus::NeedsAction), + BuyerOrderStatus::Placed + ); + assert_eq!( + BuyerOrderStatus::from(OrderStatus::Packed), + BuyerOrderStatus::Ready + ); + assert_eq!( + BuyerOrderStatus::from(OrderStatus::Declined), + BuyerOrderStatus::Declined + ); + + assert_eq!(OrdersFilter::default(), OrdersFilter::NeedsAction); + assert_eq!(OrdersFilter::All.storage_key(), "all"); + assert_eq!(OrdersFilter::NeedsAction.storage_key(), "needs_action"); + assert_eq!(OrdersFilter::Scheduled.storage_key(), "scheduled"); + assert_eq!(OrdersFilter::Packed.storage_key(), "packed"); + assert_eq!(OrdersFilter::Completed.storage_key(), "completed"); + assert_eq!(OrdersFilter::Refunded.storage_key(), "refunded"); + + assert_eq!(OrderPrimaryAction::Review.storage_key(), "review"); + assert_eq!(OrderPrimaryAction::MarkPacked.storage_key(), "mark_packed"); + assert_eq!( + OrderPrimaryAction::MarkCompleted.storage_key(), + "mark_completed" + ); + } + + #[test] + fn orders_and_pack_day_query_state_defaults_are_frozen() { + assert_eq!( + OrdersScreenQueryState::default(), + OrdersScreenQueryState { + filter: OrdersFilter::NeedsAction, + fulfillment_window_id: None, + } + ); + assert_eq!( + PackDayScreenQueryState::default(), + PackDayScreenQueryState { + fulfillment_window_id: None, + } + ); + } + + #[test] + fn pack_day_export_print_and_host_handoff_contracts_are_frozen_for_v1() { + assert_eq!( + PackDayExportArtifactKind::all_v1(), + [ + PackDayExportArtifactKind::PackSheet, + PackDayExportArtifactKind::PickupRoster, + PackDayExportArtifactKind::CustomerLabels, + ] + ); + assert_eq!( + PackDayExportArtifactKind::PackSheet.storage_key(), + "pack_sheet" + ); + assert_eq!( + PackDayExportArtifactKind::PackSheet.file_name(), + "pack_sheet.txt" + ); + assert_eq!( + PackDayExportArtifactKind::PickupRoster.file_name(), + "pickup_roster.txt" + ); + assert_eq!( + PackDayExportArtifactKind::CustomerLabels.file_name(), + "customer_labels.txt" + ); + assert_eq!(PackDayExportStatus::default(), PackDayExportStatus::Idle); + assert_eq!(PackDayExportStatus::Running.storage_key(), "running"); + assert_eq!(PackDayExportStatus::Succeeded.storage_key(), "succeeded"); + assert_eq!(PackDayExportStatus::Failed.storage_key(), "failed"); + assert_eq!( + PackDayPrintKind::all_v1(), + [ + PackDayPrintKind::PrintPackSheet, + PackDayPrintKind::PrintPickupRoster, + PackDayPrintKind::PrintCustomerLabels, + ] + ); + assert_eq!( + PackDayPrintKind::PrintPackSheet.storage_key(), + "print_pack_sheet" + ); + assert_eq!( + PackDayPrintKind::PrintPickupRoster.storage_key(), + "print_pickup_roster" + ); + assert_eq!( + PackDayPrintKind::PrintCustomerLabels.storage_key(), + "print_customer_labels" + ); + assert_eq!( + PackDayPrintKind::PrintPackSheet.artifact_kind(), + PackDayExportArtifactKind::PackSheet + ); + assert_eq!( + PackDayPrintKind::PrintPickupRoster.artifact_kind(), + PackDayExportArtifactKind::PickupRoster + ); + assert_eq!( + PackDayPrintKind::PrintCustomerLabels.artifact_kind(), + PackDayExportArtifactKind::CustomerLabels + ); + assert_eq!(PackDayPrintKind::PrintPackSheet.label_stock(), None); + assert_eq!(PackDayPrintKind::PrintPickupRoster.label_stock(), None); + assert_eq!( + PackDayPrintKind::PrintCustomerLabels.label_stock(), + Some(PackDayPrintLabelStock::Avery5160Letter30Up) + ); + assert_eq!( + PackDayPrintLabelStock::all_v1(), + [PackDayPrintLabelStock::Avery5160Letter30Up] + ); + assert_eq!( + PackDayPrintLabelStock::Avery5160Letter30Up.storage_key(), + "avery_5160_letter_30_up" + ); + assert_eq!( + PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow.storage_key(), + "customer_labels_avery_5160_overflow" + ); + assert_eq!( + PackDayBatchPrintArtifact::all_v1(), + [ + PackDayBatchPrintArtifact { + print_kind: PackDayPrintKind::PrintPackSheet, + artifact_kind: PackDayExportArtifactKind::PackSheet, + label_stock: None, + }, + PackDayBatchPrintArtifact { + print_kind: PackDayPrintKind::PrintPickupRoster, + artifact_kind: PackDayExportArtifactKind::PickupRoster, + label_stock: None, + }, + PackDayBatchPrintArtifact { + print_kind: PackDayPrintKind::PrintCustomerLabels, + artifact_kind: PackDayExportArtifactKind::CustomerLabels, + label_stock: Some(PackDayPrintLabelStock::Avery5160Letter30Up), + }, + ] + ); + assert_eq!( + PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintCustomerLabels), + PackDayBatchPrintArtifact { + print_kind: PackDayPrintKind::PrintCustomerLabels, + artifact_kind: PackDayExportArtifactKind::CustomerLabels, + label_stock: Some(PackDayPrintLabelStock::Avery5160Letter30Up), + } + ); + assert_eq!( + PackDayBatchPrintFailureKind::Preflight.storage_key(), + "preflight" + ); + assert_eq!( + PackDayBatchPrintFailureKind::QueueLaunch.storage_key(), + "queue_launch" + ); + assert_eq!( + PackDayBatchPrintFailureKind::QueueExit.storage_key(), + "queue_exit" + ); + assert_eq!( + PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow.storage_key(), + "customer_labels_avery_5160_overflow" + ); + assert_eq!( + PackDayBatchPrintStatus::default(), + PackDayBatchPrintStatus::Idle + ); + assert_eq!(PackDayBatchPrintStatus::Running.storage_key(), "running"); + assert_eq!( + PackDayBatchPrintStatus::Succeeded.storage_key(), + "succeeded" + ); + assert_eq!(PackDayBatchPrintStatus::Failed.storage_key(), "failed"); + assert_eq!(PackDayPrintStatus::default(), PackDayPrintStatus::Idle); + assert_eq!(PackDayPrintStatus::Running.storage_key(), "running"); + assert_eq!(PackDayPrintStatus::Succeeded.storage_key(), "succeeded"); + assert_eq!(PackDayPrintStatus::Failed.storage_key(), "failed"); + assert_eq!( + PackDayHostHandoffKind::all_v1(), + [ + PackDayHostHandoffKind::RevealBundle, + PackDayHostHandoffKind::OpenPackSheet, + PackDayHostHandoffKind::OpenPickupRoster, + PackDayHostHandoffKind::OpenCustomerLabels, + ] + ); + assert_eq!( + PackDayHostHandoffKind::RevealBundle.storage_key(), + "reveal_bundle" + ); + assert_eq!( + PackDayHostHandoffKind::OpenPackSheet.storage_key(), + "open_pack_sheet" + ); + assert_eq!( + PackDayHostHandoffKind::OpenPickupRoster.storage_key(), + "open_pickup_roster" + ); + assert_eq!( + PackDayHostHandoffKind::OpenCustomerLabels.storage_key(), + "open_customer_labels" + ); + assert_eq!(PackDayHostHandoffKind::RevealBundle.artifact_kind(), None); + assert_eq!( + PackDayHostHandoffKind::OpenPackSheet.artifact_kind(), + Some(PackDayExportArtifactKind::PackSheet) + ); + assert_eq!( + PackDayHostHandoffKind::OpenPickupRoster.artifact_kind(), + Some(PackDayExportArtifactKind::PickupRoster) + ); + assert_eq!( + PackDayHostHandoffKind::OpenCustomerLabels.artifact_kind(), + Some(PackDayExportArtifactKind::CustomerLabels) + ); + 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] + fn pack_day_output_order_state_freezes_the_v1_status_subset() { + assert_eq!( + PackDayOutputOrderState::all_v1(), + [ + PackDayOutputOrderState::NeedsAction, + PackDayOutputOrderState::Scheduled, + PackDayOutputOrderState::Packed, + ] + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::NeedsAction), + Some(PackDayOutputOrderState::NeedsAction) + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Scheduled), + Some(PackDayOutputOrderState::Scheduled) + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Packed), + Some(PackDayOutputOrderState::Packed) + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Completed), + None + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Declined), + None + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Refunded), + None + ); + assert_eq!( + OrderStatus::from(PackDayOutputOrderState::Packed), + OrderStatus::Packed + ); + } + + #[test] + fn pack_day_output_source_keeps_export_truth_out_of_ui_display_strings() { + let farm_id = FarmId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + let order_id = OrderId::new(); + let screen_row = PackDayPackListRow { + title: "Salad mix".to_owned(), + quantity_display: "Casey: 2 bags".to_owned(), + }; + let source = PackDayOutputSource { + fulfillment_window: PackDayOutputWindow { + fulfillment_window_id, + farm_id, + farm_display_name: "Willow farm".to_owned(), + pickup_location_label: Some("North barn".to_owned()), + starts_at: "2026-04-23T16:00:00Z".to_owned(), + ends_at: "2026-04-23T19:00:00Z".to_owned(), + }, + totals_by_product: vec![PackDayOutputProductTotal { + title: "Salad mix".to_owned(), + quantity: PackDayOutputQuantity::new(2, "bags"), + }], + pack_list: vec![PackDayOutputPackListEntry { + order_id, + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + order_state: PackDayOutputOrderState::Scheduled, + title: "Salad mix".to_owned(), + quantity: PackDayOutputQuantity::new(2, "bags"), + }], + pickup_roster: vec![PackDayOutputCustomerOrder { + order_id, + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + order_state: PackDayOutputOrderState::Scheduled, + }], + }; + + assert_eq!(screen_row.quantity_display, "Casey: 2 bags"); + assert!(!source.is_empty()); + assert_eq!(source.pack_list[0].customer_display_name, "Casey"); + assert_eq!(source.pack_list[0].quantity.value, 2); + assert_eq!(source.pack_list[0].quantity.unit_label, "bags"); + assert_eq!( + source.pickup_roster[0].order_state.storage_key(), + "scheduled" + ); + } + + #[test] + fn pack_day_export_bundle_tracks_output_directory_and_artifacts() { + let fulfillment_window_id = FulfillmentWindowId::new(); + let bundle = PackDayExportBundle { + fulfillment_window_id, + export_instance_id: PackDayExportInstanceId::new(), + generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), + bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), + artifacts: vec![ + PackDayExportArtifact { + kind: PackDayExportArtifactKind::PackSheet, + relative_path: "pack_sheet.txt".to_owned(), + }, + PackDayExportArtifact { + kind: PackDayExportArtifactKind::PickupRoster, + relative_path: "pickup_roster.txt".to_owned(), + }, + ], + }; + + assert_eq!(bundle.fulfillment_window_id, fulfillment_window_id); + assert_eq!(bundle.artifact_count(), 2); + assert!(bundle.includes_artifact(PackDayExportArtifactKind::PackSheet)); + assert!(bundle.includes_artifact(PackDayExportArtifactKind::PickupRoster)); + assert!(!bundle.includes_artifact(PackDayExportArtifactKind::CustomerLabels)); + } + + #[test] + fn orders_and_pack_day_projections_hold_truthful_execution_data() { + let fulfillment_window_id = super::FulfillmentWindowId::new(); + let farm_id = FarmId::new(); + let order_id = super::OrderId::new(); + let orders_list = OrdersListProjection { + summary: OrdersListSummary { + total_orders: 3, + needs_action_orders: 1, + scheduled_orders: 1, + packed_orders: 1, + }, + rows: vec![OrdersListRow { + order_id, + farm_id, + fulfillment_window_id: Some(fulfillment_window_id), + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + fulfillment_window_label: Some("Wednesday pickup".to_owned()), + pickup_location_label: Some("North barn".to_owned()), + status: OrderStatus::Scheduled, + primary_action: Some(OrderPrimaryAction::MarkPacked), + }], + }; + let order_detail = OrderDetailProjection { + order_id, + farm_id, + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + status: OrderStatus::Scheduled, + fulfillment_window_id: Some(fulfillment_window_id), + fulfillment_window_label: Some("Wednesday pickup".to_owned()), + pickup_location_label: Some("North barn".to_owned()), + items: vec![OrderDetailItemRow { + title: "Salad mix".to_owned(), + quantity_display: "2 bags".to_owned(), + }], + primary_action: Some(OrderPrimaryAction::MarkPacked), + recoveries: Vec::new(), + }; + let pack_day = PackDayProjection { + fulfillment_window: Some(super::FulfillmentWindowSummary { + fulfillment_window_id, + farm_id, + starts_at: "2026-04-23T16:00:00Z".to_owned(), + ends_at: "2026-04-23T19:00:00Z".to_owned(), + }), + totals_by_product: vec![PackDayProductTotalRow { + title: "Salad mix".to_owned(), + quantity_display: "8 bags".to_owned(), + }], + pack_list: vec![PackDayPackListRow { + title: "Salad mix".to_owned(), + quantity_display: "Casey: 2 bags".to_owned(), + }], + pickup_roster: vec![PackDayRosterRow { + order_id, + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + }], + reminders: ReminderFeedProjection::default(), + }; + + assert!(orders_list.summary.has_orders()); + assert!(!orders_list.is_empty()); + assert_eq!( + orders_list.rows[0].primary_action, + Some(OrderPrimaryAction::MarkPacked) + ); + assert_eq!(order_detail.items[0].quantity_display, "2 bags"); + assert!(!pack_day.is_empty()); + assert_eq!(pack_day.pickup_roster[0].order_number, "R-1001"); + } + + #[test] + fn buyer_marketplace_projections_hold_guest_capable_contract_data() { + let farm_id = FarmId::new(); + let product_id = super::ProductId::new(); + let order_id = super::OrderId::new(); + let listing = BuyerListingRow { + product_id, + farm_id, + farm_display_name: "Cedar Grove Farm".to_owned(), + listing_relays: vec!["wss://relay.example".to_owned()], + title: "Spring salad mix".to_owned(), + subtitle: Some("Tender leaves".to_owned()), + price: ProductPricePresentation { + amount_minor_units: 650, + currency_code: "USD".to_owned(), + unit_label: "bag".to_owned(), + }, + availability: ProductAvailabilitySummary { + state: ProductAvailabilityState::Scheduled, + label: "Thursday pickup".to_owned(), + }, + stock: ProductStockSummary { + quantity: Some(8), + unit_label: Some("bag".to_owned()), + state: ProductStockState::InStock, + }, + fulfillment_methods: BTreeSet::from([FarmOrderMethod::Pickup]), + next_fulfillment_window_label: Some("Thursday pickup".to_owned()), + }; + let listings = BuyerListingsProjection { + rows: vec![listing.clone()], + }; + let cart = BuyerCartProjection { + farm_id: Some(farm_id), + farm_display_name: Some("Cedar Grove Farm".to_owned()), + lines: vec![BuyerCartLineProjection { + product_id, + farm_id, + farm_display_name: "Cedar Grove Farm".to_owned(), + title: "Spring salad mix".to_owned(), + quantity: 2, + unit_price: ProductPricePresentation { + amount_minor_units: 650, + currency_code: "USD".to_owned(), + unit_label: "bag".to_owned(), + }, + line_total_minor_units: 1300, + fulfillment_summary: "Thursday pickup".to_owned(), + }], + subtotal_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + replace_confirmation: None, + }; + let checkout = BuyerCheckoutProjection { + draft: BuyerCheckoutDraft { + name: "Casey Buyer".to_owned(), + email: "casey@example.com".to_owned(), + phone: String::new(), + order_note: "Leave by the cooler".to_owned(), + }, + summary: BuyerCheckoutSummaryProjection { + farm_display_name: Some("Cedar Grove Farm".to_owned()), + fulfillment_summary: Some("Thursday pickup".to_owned()), + line_count: 1, + subtotal_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + }, + can_place_order: true, + place_order_disabled_reason: None, + }; + let orders = BuyerOrdersProjection { + rows: vec![BuyerOrdersListRow { + order_id, + farm_id, + order_number: "R-2001".to_owned(), + farm_display_name: "Cedar Grove Farm".to_owned(), + fulfillment_summary: "Thursday pickup".to_owned(), + status: BuyerOrderStatus::Scheduled, + repeat_demand: None, + }], + }; + let order_detail = BuyerOrderDetailProjection { + order_id, + farm_id, + order_number: "R-2001".to_owned(), + farm_display_name: "Cedar Grove Farm".to_owned(), + fulfillment_summary: "Thursday pickup".to_owned(), + status: BuyerOrderStatus::Scheduled, + items: vec![OrderDetailItemRow { + title: "Spring salad mix".to_owned(), + quantity_display: "2 bags".to_owned(), + }], + order_note: Some("Leave by the cooler".to_owned()), + repeat_demand: None, + }; + + assert!(!listings.is_empty()); + assert!(!cart.is_empty()); + assert!(checkout.can_place_order); + assert!(!orders.is_empty()); + assert_eq!(listing.fulfillment_methods.len(), 1); + assert_eq!(order_detail.status, BuyerOrderStatus::Scheduled); + } + + #[test] + fn today_agenda_stays_on_the_compact_order_row_contract() { + let today = TodayAgendaProjection { + orders_needing_action: vec![OrderListRow { + order_id: super::OrderId::new(), + farm_id: FarmId::new(), + fulfillment_window_id: Some(super::FulfillmentWindowId::new()), + order_number: "R-1002".to_owned(), + customer_display_name: "Morgan".to_owned(), + status: OrderStatus::NeedsAction, + }], + ..TodayAgendaProjection::default() + }; + let orders_row = OrdersListRow { + order_id: super::OrderId::new(), + farm_id: FarmId::new(), + fulfillment_window_id: None, + order_number: "R-2002".to_owned(), + customer_display_name: "Robin".to_owned(), + fulfillment_window_label: None, + pickup_location_label: None, + status: OrderStatus::Completed, + primary_action: None, + }; + + assert_eq!(today.orders_needing_action.len(), 1); + assert_eq!( + today.orders_needing_action[0].status, + OrderStatus::NeedsAction + ); + assert_eq!(orders_row.primary_action, None); + assert_eq!(orders_row.status, OrderStatus::Completed); + } + + #[test] + fn today_summary_attention_state_is_explicit() { + let quiet = TodaySummary { + farm_id: FarmId::new(), + orders_needing_action: 0, + low_stock_products: 0, + draft_products: 0, + reminders_due_soon: 0, + recovery_actions_open: 0, + }; + let busy = TodaySummary { + farm_id: FarmId::new(), + orders_needing_action: 1, + low_stock_products: 0, + draft_products: 0, + reminders_due_soon: 0, + recovery_actions_open: 0, + }; + + assert!(!quiet.has_attention_items()); + assert!(busy.has_attention_items()); + } + + #[test] + fn reminder_recovery_and_repeat_demand_contracts_are_explicit() { + let farm_id = FarmId::new(); + let order_id = OrderId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + let reminder = ReminderDeadlineProjection { + reminder_id: ReminderId::new(), + farm_id, + order_id: Some(order_id), + fulfillment_window_id: Some(fulfillment_window_id), + kind: ReminderKind::FulfillmentWindow, + surface: ReminderSurface::Today, + urgency: ReminderUrgency::DueSoon, + title: "Pickup closes soon".to_owned(), + detail: "Pack before the pickup window opens.".to_owned(), + deadline_at: "2026-04-24T15:00:00Z".to_owned(), + action_label: Some("Open pack day".to_owned()), + delivery_state: ReminderDeliveryState::Scheduled, + }; + let recovery = OrderRecoveryProjection { + recovery_record_id: RecoveryRecordId::new(), + order_id, + kind: RecoveryKind::MissedPickup, + state: RecoveryState::Open, + summary: "Customer missed pickup".to_owned(), + note: Some("Hold one extra day".to_owned()), + last_updated_at: "2026-04-24T18:00:00Z".to_owned(), + }; + let repeat_demand = RepeatDemandHandoffProjection { + order_id, + farm_id, + eligibility: RepeatDemandEligibility::Partial, + available_item_count: 2, + unavailable_item_count: 1, + }; + + let reminder_feed = ReminderFeedProjection { + items: vec![reminder.clone()], + }; + let reminder_log = ReminderLogProjection { + entries: vec![ReminderLogEntryProjection { + reminder_id: reminder.reminder_id, + kind: reminder.kind, + title: reminder.title.clone(), + recorded_at: "2026-04-24T14:00:00Z".to_owned(), + delivery_state: ReminderDeliveryState::Presented, + detail: Some(reminder.detail.clone()), + }], + }; + let recovery_queue = RecoveryQueueProjection { + items: vec![recovery.clone()], + }; + + assert_eq!(ReminderSurface::PackDay.storage_key(), "pack_day"); + assert_eq!( + ReminderKind::RefundRecovery.storage_key(), + "refund_recovery" + ); + assert_eq!(ReminderUrgency::DueSoon.storage_key(), "due_soon"); + assert_eq!( + ReminderDeliveryState::Acknowledged.storage_key(), + "acknowledged" + ); + assert_eq!( + RecoveryKind::RefundFollowUp.storage_key(), + "refund_follow_up" + ); + assert_eq!(RecoveryState::InReview.storage_key(), "in_review"); + assert_eq!( + RepeatDemandEligibility::Unavailable.storage_key(), + "unavailable" + ); + assert_eq!(reminder_feed.due_soon_count(), 1); + assert!(!reminder_log.is_empty()); + assert!(!recovery_queue.is_empty()); + assert_eq!(repeat_demand.unavailable_item_count, 1); + } + + #[test] + fn today_agenda_projection_tracks_attention_and_setup_independently() { + let calm = TodayAgendaProjection::default(); + let with_attention = TodayAgendaProjection { + draft_products: vec![ProductListRow { + product_id: super::ProductId::new(), + farm_id: FarmId::new(), + title: "Spring onions".to_owned(), + status: super::ProductStatus::Draft, + stock_count: 0, + }], + ..TodayAgendaProjection::default() + }; + let with_setup = TodayAgendaProjection { + setup_checklist: vec![TodaySetupTask { + kind: TodaySetupTaskKind::AddFulfillmentWindow, + is_complete: false, + }], + ..TodayAgendaProjection::default() + }; + + assert!(!calm.has_attention_items()); + assert!(!calm.needs_setup()); + assert!(with_attention.has_attention_items()); + assert!(!with_attention.needs_setup()); + assert!(!with_setup.has_attention_items()); + assert!(with_setup.needs_setup()); + } + + #[test] + fn today_agenda_projection_can_hold_truthful_lists() { + let projection = TodayAgendaProjection { + orders_needing_action: vec![OrderListRow { + order_id: super::OrderId::new(), + farm_id: FarmId::new(), + fulfillment_window_id: Some(super::FulfillmentWindowId::new()), + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + status: super::OrderStatus::NeedsAction, + }], + low_stock_products: vec![ProductListRow { + product_id: super::ProductId::new(), + farm_id: FarmId::new(), + title: "Carrots".to_owned(), + status: super::ProductStatus::Published, + stock_count: 2, + }], + ..TodayAgendaProjection::default() + }; + + assert_eq!(projection.orders_needing_action.len(), 1); + assert_eq!(projection.low_stock_products[0].stock_count, 2); + assert!(projection.has_attention_items()); + } + + #[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 farm_rules_projection_defaults_to_missing_v1_requirements() { + let projection = FarmRulesProjection::default(); + + assert!(projection.farm_profile.is_none()); + assert!(projection.pickup_locations.is_empty()); + assert!(projection.operating_rules.is_none()); + assert!(projection.fulfillment_windows.is_empty()); + assert!(projection.blackout_periods.is_empty()); + assert_eq!( + projection.readiness, + FarmRulesReadiness::missing_v1_basics() + ); + assert!(!projection.is_ready()); + } + + #[test] + fn farm_rules_readiness_and_timing_conflicts_are_explicit() { + let readiness = FarmRulesReadiness { + blockers: vec![FarmReadinessBlocker::MissingOperatingRules], + timing_conflicts: vec![FarmTimingConflict { + kind: FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow, + fulfillment_window_id: Some(super::FulfillmentWindowId::new()), + blackout_period_id: Some(BlackoutPeriodId::new()), + }], + }; + + assert_eq!( + FarmReadinessBlocker::MissingProfileBasics.storage_key(), + "missing_profile_basics" + ); + assert_eq!( + FarmReadinessBlocker::MissingPickupLocation.storage_key(), + "missing_pickup_location" + ); + assert_eq!( + FarmReadinessBlocker::MissingFulfillmentWindow.storage_key(), + "missing_fulfillment_window" + ); + assert_eq!( + FarmReadinessBlocker::MissingOperatingRules.storage_key(), + "missing_operating_rules" + ); + assert_eq!( + FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart.storage_key(), + "fulfillment_window_ends_before_start" + ); + assert_eq!( + FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart.storage_key(), + "fulfillment_window_cutoff_after_start" + ); + assert_eq!( + FarmTimingConflictKind::BlackoutPeriodEndsBeforeStart.storage_key(), + "blackout_period_ends_before_start" + ); + assert_eq!( + FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow.storage_key(), + "blackout_overlaps_fulfillment_window" + ); + assert!(!readiness.is_ready()); + assert!(FarmRulesReadiness::ready().is_ready()); + } + + #[test] + fn farm_rules_projection_represents_full_v1_inventory() { + let farm_id = FarmId::new(); + let pickup_location_id = PickupLocationId::new(); + let fulfillment_window_id = super::FulfillmentWindowId::new(); + let blackout_period_id = BlackoutPeriodId::new(); + let projection = super::FarmRulesProjection { + farm_profile: Some(super::FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }), + pickup_locations: vec![super::PickupLocationRecord { + pickup_location_id, + farm_id, + label: "Barn pickup".to_owned(), + address_line: "14 Orchard Lane".to_owned(), + directions: Some("Drive to the red barn.".to_owned()), + is_default: true, + }], + operating_rules: Some(super::FarmOperatingRulesRecord { + farm_id, + promise_lead_hours: 24, + substitution_policy: "ask_customer".to_owned(), + missed_pickup_policy: "hold_next_window".to_owned(), + }), + fulfillment_windows: vec![super::FulfillmentWindowRecord { + fulfillment_window_id, + farm_id, + pickup_location_id, + label: "Friday pickup".to_owned(), + starts_at: "2026-04-25T14:00:00Z".to_owned(), + ends_at: "2026-04-25T18:00:00Z".to_owned(), + order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), + }], + blackout_periods: vec![super::BlackoutPeriodRecord { + blackout_period_id, + farm_id, + label: "Spring break".to_owned(), + starts_at: "2026-05-01T00:00:00Z".to_owned(), + ends_at: "2026-05-03T23:59:59Z".to_owned(), + }], + readiness: FarmRulesReadiness::ready(), + }; + let saved_farm = super::FarmSummary { + farm_id, + display_name: "North field farm".to_owned(), + readiness: super::FarmReadiness::Ready, + }; + + assert!(projection.is_ready()); + assert_eq!( + projection + .farm_profile + .as_ref() + .map(|profile| profile.display_name.as_str()), + Some(saved_farm.display_name.as_str()) + ); + assert_eq!( + projection.pickup_locations[0].pickup_location_id, + pickup_location_id + ); + assert_eq!( + projection.fulfillment_windows[0].pickup_location_id, + pickup_location_id + ); + assert_eq!( + projection.blackout_periods[0].blackout_period_id, + blackout_period_id + ); + assert_eq!(saved_farm.readiness, super::FarmReadiness::Ready); + } + + #[test] + fn settings_preference_storage_keys_are_stable() { + assert_eq!( + SettingsPreference::AllowRelayConnections.storage_key(), + "allow_relay_connections" + ); + assert_eq!( + SettingsPreference::UseMediaServers.storage_key(), + "use_media_servers" + ); + assert_eq!(SettingsPreference::UseNip05.storage_key(), "use_nip05"); + assert_eq!( + SettingsPreference::LaunchAtLogin.storage_key(), + "launch_at_login" + ); + } + + #[test] + fn activity_kind_storage_keys_are_stable() { + assert_eq!(AppActivityKind::HomeOpened.storage_key(), "home_opened"); + assert_eq!( + AppActivityKind::SettingsOpened { + section: SettingsSection::About, + } + .storage_key(), + "settings_opened" + ); + assert_eq!( + AppActivityKind::SettingsSectionSelected { + section: SettingsSection::Settings, + } + .storage_key(), + "settings_section_selected" + ); + assert_eq!( + AppActivityKind::SettingsPreferenceUpdated { + preference: SettingsPreference::LaunchAtLogin, + enabled: true, + } + .storage_key(), + "settings_preference_updated" + ); + } + + #[test] + fn activity_context_preserves_recent_event_order() { + let first = AppActivityEvent { + activity_event_id: ActivityEventId::new(), + recorded_at: "2026-04-18T00:00:00.000Z".to_owned(), + kind: AppActivityKind::HomeOpened, + }; + let second = AppActivityEvent { + activity_event_id: ActivityEventId::new(), + recorded_at: "2026-04-18T00:01:00.000Z".to_owned(), + kind: AppActivityKind::SettingsOpened { + section: SettingsSection::About, + }, + }; + let context = AppActivityContext::from_recent_events(vec![second.clone(), first.clone()]); + + assert_eq!(context.recent_events, vec![second, first]); + } +} diff --git a/scripts/check.sh b/scripts/check.sh @@ -6,7 +6,8 @@ repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel)" cd "${repo_root}" cargo metadata --format-version 1 --no-deps -cargo test -p radroots_app_models pack_day +cargo test -p radroots_app_types pack_day +cargo test -p radroots_app_view pack_day cargo test -p radroots_app_state pack_day cargo test -p radroots_app_i18n pack_day cargo test -p radroots_app pack_day