app

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

lib.rs (150442B)


      1 #![forbid(unsafe_code)]
      2 
      3 use std::{
      4     collections::BTreeSet,
      5     fs,
      6     io::ErrorKind,
      7     path::{Path, PathBuf},
      8 };
      9 
     10 use radroots_app_sync::{
     11     AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, SyncConflict,
     12     SyncConflictStatus,
     13 };
     14 use radroots_app_view::{
     15     ActiveSurface, AppIdentityProjection, AppStartupGate, BuyerCartProjection,
     16     BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrderReviewProjection,
     17     BuyerOrdersProjection, BuyerProductDetailProjection, FarmOrderMethod, FarmReadiness,
     18     FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection,
     19     FarmSetupReadiness, FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase,
     20     LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection,
     21     OrdersScreenQueryState, PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind,
     22     PackDayBatchPrintStatus, PackDayExportArtifactKind, PackDayExportBundle,
     23     PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus,
     24     PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintLabelStock, PackDayPrintStatus,
     25     PackDayProjection, PackDayScreenQueryState, PersonalEntryProjection, ProductEditorDraft,
     26     ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort,
     27     ReminderFeedProjection, ReminderLogProjection, SelectedSurfaceProjection,
     28     SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection,
     29     TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind,
     30 };
     31 use serde::{Deserialize, Serialize};
     32 use thiserror::Error;
     33 use tracing::error;
     34 
     35 #[derive(Clone, Debug, Eq, PartialEq)]
     36 pub struct GeneralSettingsProjection {
     37     pub allow_relay_connections: bool,
     38     pub use_media_servers: bool,
     39     pub use_nip05: bool,
     40     pub launch_at_login: bool,
     41 }
     42 
     43 impl Default for GeneralSettingsProjection {
     44     fn default() -> Self {
     45         Self {
     46             allow_relay_connections: true,
     47             use_media_servers: true,
     48             use_nip05: true,
     49             launch_at_login: false,
     50         }
     51     }
     52 }
     53 
     54 impl GeneralSettingsProjection {
     55     fn set_preference(&mut self, preference: SettingsPreference, enabled: bool) {
     56         match preference {
     57             SettingsPreference::AllowRelayConnections => {
     58                 self.allow_relay_connections = enabled;
     59             }
     60             SettingsPreference::UseMediaServers => {
     61                 self.use_media_servers = enabled;
     62             }
     63             SettingsPreference::UseNip05 => {
     64                 self.use_nip05 = enabled;
     65             }
     66             SettingsPreference::LaunchAtLogin => {
     67                 self.launch_at_login = enabled;
     68             }
     69         }
     70     }
     71 }
     72 
     73 #[derive(Clone, Debug, Eq, PartialEq)]
     74 pub struct SettingsShellProjection {
     75     pub selected_section: SettingsSection,
     76     pub general: GeneralSettingsProjection,
     77 }
     78 
     79 impl Default for SettingsShellProjection {
     80     fn default() -> Self {
     81         Self::new(SettingsSection::default())
     82     }
     83 }
     84 
     85 impl SettingsShellProjection {
     86     pub fn new(selected_section: SettingsSection) -> Self {
     87         Self {
     88             selected_section,
     89             general: GeneralSettingsProjection::default(),
     90         }
     91     }
     92 }
     93 
     94 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
     95 pub struct BuyerSearchScreenQueryState {
     96     pub search_query: String,
     97     pub fulfillment_methods: BTreeSet<FarmOrderMethod>,
     98 }
     99 
    100 impl BuyerSearchScreenQueryState {
    101     pub fn new(
    102         search_query: impl Into<String>,
    103         fulfillment_methods: impl IntoIterator<Item = FarmOrderMethod>,
    104     ) -> Self {
    105         Self {
    106             search_query: search_query.into(),
    107             fulfillment_methods: fulfillment_methods.into_iter().collect(),
    108         }
    109     }
    110 }
    111 
    112 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    113 pub struct BuyerBrowseScreenProjection {
    114     pub listings: BuyerListingsProjection,
    115     pub detail: Option<BuyerProductDetailProjection>,
    116 }
    117 
    118 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    119 pub struct BuyerSearchScreenProjection {
    120     pub query: BuyerSearchScreenQueryState,
    121     pub listings: BuyerListingsProjection,
    122     pub detail: Option<BuyerProductDetailProjection>,
    123 }
    124 
    125 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    126 pub struct BuyerCartScreenProjection {
    127     pub cart: BuyerCartProjection,
    128     pub order_review: BuyerOrderReviewProjection,
    129 }
    130 
    131 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    132 pub struct BuyerOrdersScreenProjection {
    133     pub list: BuyerOrdersProjection,
    134     pub detail: Option<BuyerOrderDetailProjection>,
    135     pub has_recoverable_coordination: bool,
    136 }
    137 
    138 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    139 pub struct PersonalWorkspaceProjection {
    140     pub entry: PersonalEntryProjection,
    141     pub browse: BuyerBrowseScreenProjection,
    142     pub search: BuyerSearchScreenProjection,
    143     pub cart: BuyerCartScreenProjection,
    144     pub orders: BuyerOrdersScreenProjection,
    145 }
    146 
    147 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    148 pub struct ProductsScreenQueryState {
    149     pub search_query: String,
    150     pub filter: ProductsFilter,
    151     pub sort: ProductsSort,
    152 }
    153 
    154 impl Default for ProductsScreenQueryState {
    155     fn default() -> Self {
    156         Self {
    157             search_query: String::new(),
    158             filter: ProductsFilter::default(),
    159             sort: ProductsSort::default(),
    160         }
    161     }
    162 }
    163 
    164 impl ProductsScreenQueryState {
    165     pub fn new(
    166         search_query: impl Into<String>,
    167         filter: ProductsFilter,
    168         sort: ProductsSort,
    169     ) -> Self {
    170         Self {
    171             search_query: search_query.into(),
    172             filter,
    173             sort,
    174         }
    175     }
    176 
    177     fn set_search_query(&mut self, search_query: impl Into<String>) {
    178         self.search_query = search_query.into();
    179     }
    180 
    181     fn select_filter(&mut self, filter: ProductsFilter) {
    182         self.filter = filter;
    183     }
    184 
    185     fn select_sort(&mut self, sort: ProductsSort) {
    186         self.sort = sort;
    187     }
    188 }
    189 
    190 #[derive(Clone, Debug, Eq, PartialEq)]
    191 pub struct ProductEditorSession {
    192     pub selected_product_id: Option<ProductId>,
    193     pub draft: ProductEditorDraft,
    194     pub publish_blockers: Vec<ProductPublishBlocker>,
    195 }
    196 
    197 impl ProductEditorSession {
    198     fn new_draft(
    199         farm_readiness: &FarmWorkspaceReadinessProjection,
    200         farm_rules: &FarmRulesProjection,
    201     ) -> Self {
    202         Self::from_selection(
    203             None,
    204             ProductEditorDraft::default(),
    205             farm_readiness,
    206             farm_rules,
    207         )
    208     }
    209 
    210     fn existing(
    211         product_id: ProductId,
    212         draft: ProductEditorDraft,
    213         farm_readiness: &FarmWorkspaceReadinessProjection,
    214         farm_rules: &FarmRulesProjection,
    215     ) -> Self {
    216         Self::from_selection(Some(product_id), draft, farm_readiness, farm_rules)
    217     }
    218 
    219     fn from_selection(
    220         selected_product_id: Option<ProductId>,
    221         draft: ProductEditorDraft,
    222         farm_readiness: &FarmWorkspaceReadinessProjection,
    223         farm_rules: &FarmRulesProjection,
    224     ) -> Self {
    225         let publish_blockers = derive_product_publish_blockers(&draft, farm_readiness, farm_rules);
    226 
    227         Self {
    228             selected_product_id,
    229             draft,
    230             publish_blockers,
    231         }
    232     }
    233 
    234     fn replace_draft(
    235         &mut self,
    236         draft: ProductEditorDraft,
    237         farm_readiness: &FarmWorkspaceReadinessProjection,
    238         farm_rules: &FarmRulesProjection,
    239     ) {
    240         self.publish_blockers = derive_product_publish_blockers(&draft, farm_readiness, farm_rules);
    241         self.draft = draft;
    242     }
    243 }
    244 
    245 #[derive(Clone, Debug, Eq, PartialEq)]
    246 pub enum ProductEditorState {
    247     Closed,
    248     Open(ProductEditorSession),
    249 }
    250 
    251 impl Default for ProductEditorState {
    252     fn default() -> Self {
    253         Self::Closed
    254     }
    255 }
    256 
    257 impl ProductEditorState {
    258     fn open_new_draft(
    259         &mut self,
    260         farm_readiness: &FarmWorkspaceReadinessProjection,
    261         farm_rules: &FarmRulesProjection,
    262     ) {
    263         *self = Self::Open(ProductEditorSession::new_draft(farm_readiness, farm_rules));
    264     }
    265 
    266     fn open_existing(
    267         &mut self,
    268         product_id: ProductId,
    269         draft: ProductEditorDraft,
    270         farm_readiness: &FarmWorkspaceReadinessProjection,
    271         farm_rules: &FarmRulesProjection,
    272     ) {
    273         *self = Self::Open(ProductEditorSession::existing(
    274             product_id,
    275             draft,
    276             farm_readiness,
    277             farm_rules,
    278         ));
    279     }
    280 
    281     fn replace_draft(
    282         &mut self,
    283         draft: ProductEditorDraft,
    284         farm_readiness: &FarmWorkspaceReadinessProjection,
    285         farm_rules: &FarmRulesProjection,
    286     ) {
    287         if let Self::Open(session) = self {
    288             session.replace_draft(draft, farm_readiness, farm_rules);
    289         }
    290     }
    291 
    292     fn close(&mut self) {
    293         *self = Self::Closed;
    294     }
    295 }
    296 
    297 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    298 pub struct ProductsScreenProjection {
    299     pub list: ProductsListProjection,
    300     pub query: ProductsScreenQueryState,
    301     pub editor: ProductEditorState,
    302 }
    303 
    304 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    305 pub struct OrdersScreenProjection {
    306     pub list: OrdersListProjection,
    307     pub query: OrdersScreenQueryState,
    308     pub reminders: ReminderFeedProjection,
    309     pub detail: Option<OrderDetailProjection>,
    310 }
    311 
    312 impl OrdersScreenProjection {
    313     fn select_filter(&mut self, filter: OrdersFilter) {
    314         self.query.filter = filter;
    315         self.detail = None;
    316     }
    317 
    318     fn select_fulfillment_window(&mut self, fulfillment_window_id: Option<FulfillmentWindowId>) {
    319         self.query.fulfillment_window_id = fulfillment_window_id;
    320         self.detail = None;
    321     }
    322 
    323     fn replace_detail(&mut self, detail: Option<OrderDetailProjection>) {
    324         self.detail = detail;
    325     }
    326 }
    327 
    328 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    329 pub struct PackDayScreenProjection {
    330     pub query: PackDayScreenQueryState,
    331     pub projection: PackDayProjection,
    332     pub export: PackDayExportProjection,
    333     pub print: PackDayPrintProjection,
    334     pub batch_print: PackDayBatchPrintProjection,
    335     pub host_handoff: PackDayHostHandoffProjection,
    336 }
    337 
    338 impl PackDayScreenProjection {
    339     fn select_fulfillment_window(&mut self, fulfillment_window_id: Option<FulfillmentWindowId>) {
    340         if self.query.fulfillment_window_id != fulfillment_window_id {
    341             self.export = PackDayExportProjection::default();
    342             self.print = PackDayPrintProjection::default();
    343             self.batch_print = PackDayBatchPrintProjection::default();
    344             self.host_handoff = PackDayHostHandoffProjection::default();
    345         }
    346         self.query.fulfillment_window_id = fulfillment_window_id;
    347     }
    348 
    349     fn replace_projection(&mut self, projection: PackDayProjection) {
    350         let previous_window_id = self
    351             .projection
    352             .fulfillment_window
    353             .as_ref()
    354             .map(|window| window.fulfillment_window_id);
    355         let next_window_id = projection
    356             .fulfillment_window
    357             .as_ref()
    358             .map(|window| window.fulfillment_window_id);
    359 
    360         if previous_window_id != next_window_id {
    361             self.export = PackDayExportProjection::default();
    362             self.print = PackDayPrintProjection::default();
    363             self.batch_print = PackDayBatchPrintProjection::default();
    364             self.host_handoff = PackDayHostHandoffProjection::default();
    365         }
    366 
    367         self.projection = projection;
    368     }
    369 
    370     fn replace_export(&mut self, export: PackDayExportProjection) {
    371         if self.export != export {
    372             self.print = PackDayPrintProjection::default();
    373             self.batch_print = PackDayBatchPrintProjection::default();
    374             self.host_handoff = PackDayHostHandoffProjection::default();
    375         }
    376         self.export = export;
    377     }
    378 
    379     fn replace_print(&mut self, print: PackDayPrintProjection) {
    380         self.print = print;
    381     }
    382 
    383     fn replace_batch_print(&mut self, batch_print: PackDayBatchPrintProjection) {
    384         self.batch_print = batch_print;
    385     }
    386 
    387     fn replace_host_handoff(&mut self, host_handoff: PackDayHostHandoffProjection) {
    388         self.host_handoff = host_handoff;
    389     }
    390 }
    391 
    392 #[derive(Clone, Debug, Eq, PartialEq)]
    393 pub struct PackDayExportRequest {
    394     pub fulfillment_window_id: FulfillmentWindowId,
    395     pub artifact_kinds: Vec<PackDayExportArtifactKind>,
    396 }
    397 
    398 impl PackDayExportRequest {
    399     pub fn for_fulfillment_window(fulfillment_window_id: FulfillmentWindowId) -> Self {
    400         Self {
    401             fulfillment_window_id,
    402             artifact_kinds: Vec::from(PackDayExportArtifactKind::all_v1()),
    403         }
    404     }
    405 }
    406 
    407 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    408 pub struct PackDayExportProjection {
    409     pub status: PackDayExportStatus,
    410     pub request: Option<PackDayExportRequest>,
    411     pub bundle: Option<PackDayExportBundle>,
    412     pub error_message: Option<String>,
    413 }
    414 
    415 impl PackDayExportProjection {
    416     pub fn running(request: PackDayExportRequest) -> Self {
    417         Self {
    418             status: PackDayExportStatus::Running,
    419             request: Some(request),
    420             bundle: None,
    421             error_message: None,
    422         }
    423     }
    424 
    425     pub fn succeeded(request: PackDayExportRequest, bundle: PackDayExportBundle) -> Self {
    426         Self {
    427             status: PackDayExportStatus::Succeeded,
    428             request: Some(request),
    429             bundle: Some(bundle),
    430             error_message: None,
    431         }
    432     }
    433 
    434     pub fn failed(request: PackDayExportRequest, message: impl Into<String>) -> Self {
    435         Self {
    436             status: PackDayExportStatus::Failed,
    437             request: Some(request),
    438             bundle: None,
    439             error_message: Some(message.into()),
    440         }
    441     }
    442 }
    443 
    444 #[derive(Clone, Debug, Eq, PartialEq)]
    445 pub struct PackDayPrintRequest {
    446     pub fulfillment_window_id: FulfillmentWindowId,
    447     pub export_instance_id: PackDayExportInstanceId,
    448     pub kind: PackDayPrintKind,
    449     pub label_stock: Option<PackDayPrintLabelStock>,
    450 }
    451 
    452 impl PackDayPrintRequest {
    453     pub fn for_bundle(kind: PackDayPrintKind, bundle: &PackDayExportBundle) -> Self {
    454         Self {
    455             fulfillment_window_id: bundle.fulfillment_window_id,
    456             export_instance_id: bundle.export_instance_id,
    457             kind,
    458             label_stock: kind.label_stock(),
    459         }
    460     }
    461 }
    462 
    463 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    464 pub struct PackDayPrintProjection {
    465     pub status: PackDayPrintStatus,
    466     pub request: Option<PackDayPrintRequest>,
    467     pub failure: Option<PackDayPrintFailureKind>,
    468 }
    469 
    470 impl PackDayPrintProjection {
    471     pub fn running(request: PackDayPrintRequest) -> Self {
    472         Self {
    473             status: PackDayPrintStatus::Running,
    474             request: Some(request),
    475             failure: None,
    476         }
    477     }
    478 
    479     pub fn succeeded(request: PackDayPrintRequest) -> Self {
    480         Self {
    481             status: PackDayPrintStatus::Succeeded,
    482             request: Some(request),
    483             failure: None,
    484         }
    485     }
    486 
    487     pub fn failed(request: PackDayPrintRequest) -> Self {
    488         Self::failed_with_failure(request, None)
    489     }
    490 
    491     pub fn failed_with_kind(
    492         request: PackDayPrintRequest,
    493         failure: PackDayPrintFailureKind,
    494     ) -> Self {
    495         Self::failed_with_failure(request, Some(failure))
    496     }
    497 
    498     fn failed_with_failure(
    499         request: PackDayPrintRequest,
    500         failure: Option<PackDayPrintFailureKind>,
    501     ) -> Self {
    502         Self {
    503             status: PackDayPrintStatus::Failed,
    504             request: Some(request),
    505             failure,
    506         }
    507     }
    508 }
    509 
    510 #[derive(Clone, Debug, Eq, PartialEq)]
    511 pub struct PackDayBatchPrintRequest {
    512     pub fulfillment_window_id: FulfillmentWindowId,
    513     pub export_instance_id: PackDayExportInstanceId,
    514     pub artifacts: Vec<PackDayBatchPrintArtifact>,
    515 }
    516 
    517 impl PackDayBatchPrintRequest {
    518     pub fn for_bundle(bundle: &PackDayExportBundle) -> Self {
    519         Self {
    520             fulfillment_window_id: bundle.fulfillment_window_id,
    521             export_instance_id: bundle.export_instance_id,
    522             artifacts: Vec::from(PackDayBatchPrintArtifact::all_v1()),
    523         }
    524     }
    525 }
    526 
    527 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    528 pub struct PackDayBatchPrintProjection {
    529     pub status: PackDayBatchPrintStatus,
    530     pub request: Option<PackDayBatchPrintRequest>,
    531     pub failed_artifact: Option<PackDayBatchPrintArtifact>,
    532     pub failure: Option<PackDayBatchPrintFailureKind>,
    533 }
    534 
    535 impl PackDayBatchPrintProjection {
    536     pub fn running(request: PackDayBatchPrintRequest) -> Self {
    537         Self {
    538             status: PackDayBatchPrintStatus::Running,
    539             request: Some(request),
    540             failed_artifact: None,
    541             failure: None,
    542         }
    543     }
    544 
    545     pub fn succeeded(request: PackDayBatchPrintRequest) -> Self {
    546         Self {
    547             status: PackDayBatchPrintStatus::Succeeded,
    548             request: Some(request),
    549             failed_artifact: None,
    550             failure: None,
    551         }
    552     }
    553 
    554     pub fn failed(
    555         request: PackDayBatchPrintRequest,
    556         failed_artifact: Option<PackDayBatchPrintArtifact>,
    557         failure: PackDayBatchPrintFailureKind,
    558     ) -> Self {
    559         Self {
    560             status: PackDayBatchPrintStatus::Failed,
    561             request: Some(request),
    562             failed_artifact,
    563             failure: Some(failure),
    564         }
    565     }
    566 }
    567 
    568 #[derive(Clone, Debug, Eq, PartialEq)]
    569 pub struct PackDayHostHandoffRequest {
    570     pub fulfillment_window_id: FulfillmentWindowId,
    571     pub kind: PackDayHostHandoffKind,
    572     pub bundle_directory: String,
    573 }
    574 
    575 impl PackDayHostHandoffRequest {
    576     pub fn for_bundle(kind: PackDayHostHandoffKind, bundle: &PackDayExportBundle) -> Self {
    577         Self {
    578             fulfillment_window_id: bundle.fulfillment_window_id,
    579             kind,
    580             bundle_directory: bundle.bundle_directory.clone(),
    581         }
    582     }
    583 }
    584 
    585 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    586 pub struct PackDayHostHandoffProjection {
    587     pub status: PackDayHostHandoffStatus,
    588     pub request: Option<PackDayHostHandoffRequest>,
    589     pub error_message: Option<String>,
    590 }
    591 
    592 impl PackDayHostHandoffProjection {
    593     pub fn running(request: PackDayHostHandoffRequest) -> Self {
    594         Self {
    595             status: PackDayHostHandoffStatus::Running,
    596             request: Some(request),
    597             error_message: None,
    598         }
    599     }
    600 
    601     pub fn succeeded(request: PackDayHostHandoffRequest) -> Self {
    602         Self {
    603             status: PackDayHostHandoffStatus::Succeeded,
    604             request: Some(request),
    605             error_message: None,
    606         }
    607     }
    608 
    609     pub fn failed(request: PackDayHostHandoffRequest, message: impl Into<String>) -> Self {
    610         Self {
    611             status: PackDayHostHandoffStatus::Failed,
    612             request: Some(request),
    613             error_message: Some(message.into()),
    614         }
    615     }
    616 }
    617 
    618 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
    619 pub enum FarmSetupFlowStage {
    620     #[default]
    621     Onboarding,
    622     Editing,
    623 }
    624 
    625 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
    626 pub enum FarmWorkspaceStatus {
    627     #[default]
    628     NoFarm,
    629     SetupRequired,
    630     Ready,
    631 }
    632 
    633 #[derive(Clone, Debug, Default, Eq, PartialEq)]
    634 pub struct FarmWorkspaceReadinessProjection {
    635     pub has_saved_farm: bool,
    636     pub status: FarmWorkspaceStatus,
    637     pub setup_blockers: Vec<FarmSetupBlocker>,
    638     pub rules_blockers: Vec<FarmReadinessBlocker>,
    639     pub timing_conflicts: Vec<FarmTimingConflict>,
    640 }
    641 
    642 impl FarmWorkspaceReadinessProjection {
    643     pub const fn needs_setup(&self) -> bool {
    644         matches!(self.status, FarmWorkspaceStatus::SetupRequired)
    645     }
    646 
    647     pub fn coarse_readiness(&self) -> Option<FarmReadiness> {
    648         self.has_saved_farm.then_some(if self.needs_setup() {
    649             FarmReadiness::Incomplete
    650         } else {
    651             FarmReadiness::Ready
    652         })
    653     }
    654 
    655     fn has_rules_blocker(&self, blocker: FarmReadinessBlocker) -> bool {
    656         self.rules_blockers.contains(&blocker)
    657     }
    658 }
    659 
    660 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    661 pub enum HomeRoute {
    662     Blocked,
    663     SetupRequired,
    664     Personal,
    665     FarmSetupOnboarding,
    666     FarmSetupForm,
    667     Today,
    668 }
    669 
    670 #[derive(Clone, Debug, Eq, PartialEq)]
    671 pub struct AppShellProjection {
    672     pub active_surface: ActiveSurface,
    673     pub selected_section: ShellSection,
    674     pub settings: SettingsShellProjection,
    675 }
    676 
    677 impl Default for AppShellProjection {
    678     fn default() -> Self {
    679         Self::new(ActiveSurface::Personal, ShellSection::Home)
    680     }
    681 }
    682 
    683 impl AppShellProjection {
    684     pub fn new(active_surface: ActiveSurface, selected_section: ShellSection) -> Self {
    685         let settings = match selected_section {
    686             ShellSection::Settings(section) => SettingsShellProjection::new(section),
    687             _ => SettingsShellProjection::default(),
    688         };
    689 
    690         Self {
    691             active_surface: selected_section.surface().unwrap_or(active_surface),
    692             selected_section,
    693             settings,
    694         }
    695     }
    696 
    697     pub fn for_surface(active_surface: ActiveSurface) -> Self {
    698         Self::new(
    699             active_surface,
    700             ShellSection::default_for_surface(active_surface),
    701         )
    702     }
    703 
    704     pub fn for_settings(active_surface: ActiveSurface, selected_section: SettingsSection) -> Self {
    705         Self::new(active_surface, ShellSection::Settings(selected_section))
    706     }
    707 
    708     fn select_section(&mut self, selected_section: ShellSection) {
    709         if let Some(active_surface) = selected_section.surface() {
    710             self.active_surface = active_surface;
    711         }
    712         self.selected_section = selected_section;
    713 
    714         if let ShellSection::Settings(settings_section) = selected_section {
    715             self.settings.selected_section = settings_section;
    716         }
    717     }
    718 
    719     fn select_active_surface(&mut self, active_surface: ActiveSurface) {
    720         self.active_surface = active_surface;
    721         match active_surface {
    722             ActiveSurface::Personal => {
    723                 if matches!(
    724                     self.selected_section,
    725                     ShellSection::Home | ShellSection::Account | ShellSection::Farmer(_)
    726                 ) {
    727                     self.selected_section = ShellSection::default_for_surface(active_surface);
    728                 }
    729             }
    730             ActiveSurface::Farmer => {
    731                 if matches!(
    732                     self.selected_section,
    733                     ShellSection::Home | ShellSection::Account | ShellSection::Personal(_)
    734                 ) {
    735                     self.selected_section = ShellSection::default_for_surface(active_surface);
    736                 }
    737             }
    738         }
    739     }
    740 
    741     fn select_settings_section(&mut self, selected_section: SettingsSection) {
    742         self.settings.selected_section = selected_section;
    743 
    744         if matches!(self.selected_section, ShellSection::Settings(_)) {
    745             self.selected_section = ShellSection::Settings(selected_section);
    746         }
    747     }
    748 }
    749 
    750 #[derive(Clone, Debug, Eq, PartialEq)]
    751 pub struct AppProjection {
    752     pub shell: AppShellProjection,
    753     pub identity: AppIdentityProjection,
    754     pub startup_gate: AppStartupGate,
    755     pub sync: AppSyncProjection,
    756     pub logged_out_startup: LoggedOutStartupProjection,
    757     pub personal: PersonalWorkspaceProjection,
    758     pub today: TodayAgendaProjection,
    759     pub products: ProductsScreenProjection,
    760     pub orders: OrdersScreenProjection,
    761     pub pack_day: PackDayScreenProjection,
    762     pub reminder_log: ReminderLogProjection,
    763     pub farm_setup: FarmSetupProjection,
    764     pub farm_rules: FarmRulesProjection,
    765     pub farm_readiness: FarmWorkspaceReadinessProjection,
    766     pub farm_setup_flow_stage: FarmSetupFlowStage,
    767 }
    768 
    769 impl AppProjection {
    770     pub fn new(
    771         shell: AppShellProjection,
    772         identity: AppIdentityProjection,
    773         today: TodayAgendaProjection,
    774     ) -> Self {
    775         Self::with_farm_setup(shell, identity, today, FarmSetupProjection::default())
    776     }
    777 
    778     pub fn with_farm_setup(
    779         shell: AppShellProjection,
    780         identity: AppIdentityProjection,
    781         today: TodayAgendaProjection,
    782         farm_setup: FarmSetupProjection,
    783     ) -> Self {
    784         let mut projection = Self {
    785             shell,
    786             identity,
    787             startup_gate: AppStartupGate::default(),
    788             sync: AppSyncProjection::default(),
    789             logged_out_startup: LoggedOutStartupProjection::default(),
    790             personal: PersonalWorkspaceProjection::default(),
    791             today,
    792             products: ProductsScreenProjection::default(),
    793             orders: OrdersScreenProjection::default(),
    794             pack_day: PackDayScreenProjection::default(),
    795             reminder_log: ReminderLogProjection::default(),
    796             farm_setup,
    797             farm_rules: FarmRulesProjection::default(),
    798             farm_readiness: FarmWorkspaceReadinessProjection::default(),
    799             farm_setup_flow_stage: FarmSetupFlowStage::default(),
    800         };
    801         sync_projection(&mut projection);
    802 
    803         projection
    804     }
    805 
    806     pub fn home_route(&self) -> HomeRoute {
    807         match self.startup_gate {
    808             AppStartupGate::Blocked => HomeRoute::Blocked,
    809             AppStartupGate::SetupRequired => HomeRoute::SetupRequired,
    810             AppStartupGate::Personal => HomeRoute::Personal,
    811             AppStartupGate::Farmer if self.farm_setup.has_saved_farm() => HomeRoute::Today,
    812             AppStartupGate::Farmer
    813                 if self.farm_setup.readiness == FarmSetupReadiness::NotStarted
    814                     && self.farm_setup_flow_stage == FarmSetupFlowStage::Onboarding =>
    815             {
    816                 HomeRoute::FarmSetupOnboarding
    817             }
    818             AppStartupGate::Farmer => HomeRoute::FarmSetupForm,
    819         }
    820     }
    821 }
    822 
    823 impl Default for AppProjection {
    824     fn default() -> Self {
    825         Self::new(
    826             AppShellProjection::default(),
    827             AppIdentityProjection::default(),
    828             TodayAgendaProjection::default(),
    829         )
    830     }
    831 }
    832 
    833 pub const APP_STATE_FILE_NAME: &str = "state.json";
    834 const APP_STATE_SCHEMA_VERSION: u32 = 1;
    835 
    836 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
    837 pub struct PersistedShellProjection {
    838     pub selected_section: ShellSection,
    839     pub settings_section: SettingsSection,
    840 }
    841 
    842 impl Default for PersistedShellProjection {
    843     fn default() -> Self {
    844         Self {
    845             selected_section: ShellSection::Home,
    846             settings_section: SettingsSection::default(),
    847         }
    848     }
    849 }
    850 
    851 impl PersistedShellProjection {
    852     fn from_shell(shell: &AppShellProjection) -> Self {
    853         Self {
    854             selected_section: shell.selected_section,
    855             settings_section: shell.settings.selected_section,
    856         }
    857     }
    858 
    859     fn to_shell_projection(&self) -> AppShellProjection {
    860         let mut shell = AppShellProjection::new(ActiveSurface::Personal, self.selected_section);
    861         shell.settings.selected_section = self.settings_section;
    862         if matches!(shell.selected_section, ShellSection::Settings(_)) {
    863             shell.selected_section = ShellSection::Settings(self.settings_section);
    864         }
    865 
    866         shell
    867     }
    868 }
    869 
    870 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    871 pub struct PersistedBuyerProjection {
    872     pub search_query: BuyerSearchScreenQueryState,
    873     pub browse_detail_product_id: Option<ProductId>,
    874     pub search_detail_product_id: Option<ProductId>,
    875     pub orders_detail_order_id: Option<OrderId>,
    876 }
    877 
    878 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    879 pub struct PersistedSellerProjection {
    880     pub products_query: ProductsScreenQueryState,
    881     pub product_editor_product_id: Option<ProductId>,
    882     pub orders_query: OrdersScreenQueryState,
    883     pub order_detail_order_id: Option<OrderId>,
    884     pub pack_day_query: PackDayScreenQueryState,
    885 }
    886 
    887 #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
    888 pub struct PersistedAppState {
    889     pub shell: PersistedShellProjection,
    890     pub logged_out_startup: LoggedOutStartupProjection,
    891     pub buyer: PersistedBuyerProjection,
    892     pub seller: PersistedSellerProjection,
    893 }
    894 
    895 impl PersistedAppState {
    896     pub fn from_projection(projection: &AppProjection) -> Self {
    897         Self {
    898             shell: PersistedShellProjection::from_shell(&projection.shell),
    899             logged_out_startup: projection.logged_out_startup.clone(),
    900             buyer: PersistedBuyerProjection {
    901                 search_query: projection.personal.search.query.clone(),
    902                 browse_detail_product_id: projection
    903                     .personal
    904                     .browse
    905                     .detail
    906                     .as_ref()
    907                     .map(|detail| detail.listing.product_id),
    908                 search_detail_product_id: projection
    909                     .personal
    910                     .search
    911                     .detail
    912                     .as_ref()
    913                     .map(|detail| detail.listing.product_id),
    914                 orders_detail_order_id: projection
    915                     .personal
    916                     .orders
    917                     .detail
    918                     .as_ref()
    919                     .map(|detail| detail.order_id),
    920             },
    921             seller: PersistedSellerProjection {
    922                 products_query: projection.products.query.clone(),
    923                 product_editor_product_id: match &projection.products.editor {
    924                     ProductEditorState::Open(session) => session.selected_product_id,
    925                     ProductEditorState::Closed => None,
    926                 },
    927                 orders_query: projection.orders.query.clone(),
    928                 order_detail_order_id: projection
    929                     .orders
    930                     .detail
    931                     .as_ref()
    932                     .map(|detail| detail.order_id),
    933                 pack_day_query: projection.pack_day.query.clone(),
    934             },
    935         }
    936     }
    937 
    938     fn sanitized_for_restart(&self) -> Self {
    939         let mut state = self.clone();
    940 
    941         if state.logged_out_startup.phase == LoggedOutStartupPhase::GenerateKeyStarting {
    942             state.logged_out_startup.phase = LoggedOutStartupPhase::IdentityChoice;
    943         }
    944 
    945         state
    946     }
    947 
    948     fn to_projection(&self) -> AppProjection {
    949         let mut projection = AppProjection {
    950             shell: self.shell.to_shell_projection(),
    951             identity: AppIdentityProjection::default(),
    952             startup_gate: AppStartupGate::SetupRequired,
    953             sync: AppSyncProjection::default(),
    954             logged_out_startup: self.logged_out_startup.clone(),
    955             personal: PersonalWorkspaceProjection {
    956                 entry: AppIdentityProjection::default().personal_entry(),
    957                 search: BuyerSearchScreenProjection {
    958                     query: self.buyer.search_query.clone(),
    959                     ..BuyerSearchScreenProjection::default()
    960                 },
    961                 ..PersonalWorkspaceProjection::default()
    962             },
    963             today: TodayAgendaProjection::default(),
    964             products: ProductsScreenProjection {
    965                 query: self.seller.products_query.clone(),
    966                 ..ProductsScreenProjection::default()
    967             },
    968             orders: OrdersScreenProjection {
    969                 query: self.seller.orders_query.clone(),
    970                 ..OrdersScreenProjection::default()
    971             },
    972             pack_day: PackDayScreenProjection {
    973                 query: self.seller.pack_day_query.clone(),
    974                 ..PackDayScreenProjection::default()
    975             },
    976             reminder_log: ReminderLogProjection::default(),
    977             farm_setup: FarmSetupProjection::default(),
    978             farm_rules: FarmRulesProjection::default(),
    979             farm_readiness: FarmWorkspaceReadinessProjection::default(),
    980             farm_setup_flow_stage: FarmSetupFlowStage::default(),
    981         };
    982         sync_farm_setup_to_today(&mut projection.farm_setup, &projection.today);
    983         projection.farm_readiness =
    984             derive_farm_workspace_readiness(&projection.farm_setup, &projection.farm_rules);
    985         sync_coarse_farm_readiness(
    986             &mut projection.farm_setup,
    987             &mut projection.today,
    988             &projection.farm_readiness,
    989         );
    990         projection.today.setup_checklist =
    991             derive_today_setup_checklist(&projection.farm_readiness, &projection.products.list);
    992         sync_product_editor_publish_blockers(
    993             &mut projection.products.editor,
    994             &projection.farm_readiness,
    995             &projection.farm_rules,
    996         );
    997         projection.startup_gate = projection.identity.startup_gate();
    998         projection.personal.entry = projection.identity.personal_entry();
    999         sync_logged_out_startup(&mut projection.logged_out_startup, projection.startup_gate);
   1000         sync_farm_setup_flow_stage(
   1001             &mut projection.farm_setup_flow_stage,
   1002             projection.startup_gate,
   1003             projection.farm_setup.has_saved_farm(),
   1004         );
   1005 
   1006         projection
   1007     }
   1008 }
   1009 
   1010 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
   1011 struct PersistedAppStateEnvelope {
   1012     version: u32,
   1013     state: PersistedAppState,
   1014 }
   1015 
   1016 impl PersistedAppStateEnvelope {
   1017     fn new(state: PersistedAppState) -> Self {
   1018         Self {
   1019             version: APP_STATE_SCHEMA_VERSION,
   1020             state,
   1021         }
   1022     }
   1023 }
   1024 
   1025 #[derive(Clone, Debug, Eq, PartialEq)]
   1026 pub enum AppStateCommand {
   1027     SelectActiveSurface(ActiveSurface),
   1028     SelectSection(ShellSection),
   1029     SelectSettingsSection(SettingsSection),
   1030     ShowStartupIdentityChoice,
   1031     BeginGenerateKeyStartup,
   1032     ShowStartupSignerEntry,
   1033     SetStartupSignerSourceInput(String),
   1034     ResetLoggedOutStartup,
   1035     ReplaceIdentityProjection(AppIdentityProjection),
   1036     ReplaceSyncProjection(AppSyncProjection),
   1037     ReplacePersonalProjection(PersonalWorkspaceProjection),
   1038     ReplaceFarmSetupProjection(FarmSetupProjection),
   1039     ReplaceFarmRulesProjection(FarmRulesProjection),
   1040     SelectFarmSetupFlowStage(FarmSetupFlowStage),
   1041     SetSettingsPreference {
   1042         preference: SettingsPreference,
   1043         enabled: bool,
   1044     },
   1045     ReplaceTodayAgenda(TodayAgendaProjection),
   1046     SetProductsSearchQuery(String),
   1047     SelectProductsFilter(ProductsFilter),
   1048     SelectProductsSort(ProductsSort),
   1049     ReplaceProductsList(ProductsListProjection),
   1050     SelectOrdersFilter(OrdersFilter),
   1051     SelectOrdersFulfillmentWindow(Option<FulfillmentWindowId>),
   1052     ReplaceOrdersList(OrdersListProjection),
   1053     ReplaceOrdersReminders(ReminderFeedProjection),
   1054     ReplaceReminderLog(ReminderLogProjection),
   1055     ReplaceOrderDetail(Option<OrderDetailProjection>),
   1056     SetPackDayFulfillmentWindow(Option<FulfillmentWindowId>),
   1057     ReplacePackDayProjection(PackDayProjection),
   1058     BeginPackDayExport(PackDayExportRequest),
   1059     SucceedPackDayExport {
   1060         request: PackDayExportRequest,
   1061         bundle: PackDayExportBundle,
   1062     },
   1063     FailPackDayExport {
   1064         request: PackDayExportRequest,
   1065         message: String,
   1066     },
   1067     ResetPackDayExport,
   1068     BeginPackDayPrint(PackDayPrintRequest),
   1069     SucceedPackDayPrint(PackDayPrintRequest),
   1070     FailPackDayPrint(PackDayPrintRequest),
   1071     FailPackDayPrintWithKind {
   1072         request: PackDayPrintRequest,
   1073         failure: PackDayPrintFailureKind,
   1074     },
   1075     ResetPackDayPrint,
   1076     BeginPackDayBatchPrint(PackDayBatchPrintRequest),
   1077     SucceedPackDayBatchPrint(PackDayBatchPrintRequest),
   1078     FailPackDayBatchPrint {
   1079         request: PackDayBatchPrintRequest,
   1080         failed_artifact: Option<PackDayBatchPrintArtifact>,
   1081         failure: PackDayBatchPrintFailureKind,
   1082     },
   1083     ResetPackDayBatchPrint,
   1084     BeginPackDayHostHandoff(PackDayHostHandoffRequest),
   1085     SucceedPackDayHostHandoff(PackDayHostHandoffRequest),
   1086     FailPackDayHostHandoff {
   1087         request: PackDayHostHandoffRequest,
   1088         message: String,
   1089     },
   1090     ResetPackDayHostHandoff,
   1091     OpenNewProductEditor,
   1092     OpenExistingProductEditor {
   1093         product_id: ProductId,
   1094         draft: ProductEditorDraft,
   1095     },
   1096     ReplaceProductEditorDraft(ProductEditorDraft),
   1097     CloseProductEditor,
   1098 }
   1099 
   1100 impl AppStateCommand {
   1101     pub const fn select_active_surface(surface: ActiveSurface) -> Self {
   1102         Self::SelectActiveSurface(surface)
   1103     }
   1104 
   1105     pub const fn select_settings_section(section: SettingsSection) -> Self {
   1106         Self::SelectSettingsSection(section)
   1107     }
   1108 
   1109     pub const fn show_startup_identity_choice() -> Self {
   1110         Self::ShowStartupIdentityChoice
   1111     }
   1112 
   1113     pub const fn begin_generate_key_startup() -> Self {
   1114         Self::BeginGenerateKeyStartup
   1115     }
   1116 
   1117     pub const fn show_startup_signer_entry() -> Self {
   1118         Self::ShowStartupSignerEntry
   1119     }
   1120 
   1121     pub fn set_startup_signer_source_input(source_input: impl Into<String>) -> Self {
   1122         Self::SetStartupSignerSourceInput(source_input.into())
   1123     }
   1124 
   1125     pub const fn reset_logged_out_startup() -> Self {
   1126         Self::ResetLoggedOutStartup
   1127     }
   1128 
   1129     pub fn replace_identity_projection(projection: AppIdentityProjection) -> Self {
   1130         Self::ReplaceIdentityProjection(projection)
   1131     }
   1132 
   1133     pub fn replace_sync_projection(projection: AppSyncProjection) -> Self {
   1134         Self::ReplaceSyncProjection(projection)
   1135     }
   1136 
   1137     pub fn replace_personal_projection(projection: PersonalWorkspaceProjection) -> Self {
   1138         Self::ReplacePersonalProjection(projection)
   1139     }
   1140 
   1141     pub fn replace_farm_setup_projection(projection: FarmSetupProjection) -> Self {
   1142         Self::ReplaceFarmSetupProjection(projection)
   1143     }
   1144 
   1145     pub fn replace_farm_rules_projection(projection: FarmRulesProjection) -> Self {
   1146         Self::ReplaceFarmRulesProjection(projection)
   1147     }
   1148 
   1149     pub const fn select_farm_setup_flow_stage(stage: FarmSetupFlowStage) -> Self {
   1150         Self::SelectFarmSetupFlowStage(stage)
   1151     }
   1152 
   1153     pub fn replace_today_agenda(projection: TodayAgendaProjection) -> Self {
   1154         Self::ReplaceTodayAgenda(projection)
   1155     }
   1156 
   1157     pub fn set_products_search_query(search_query: impl Into<String>) -> Self {
   1158         Self::SetProductsSearchQuery(search_query.into())
   1159     }
   1160 
   1161     pub const fn select_products_filter(filter: ProductsFilter) -> Self {
   1162         Self::SelectProductsFilter(filter)
   1163     }
   1164 
   1165     pub const fn select_products_sort(sort: ProductsSort) -> Self {
   1166         Self::SelectProductsSort(sort)
   1167     }
   1168 
   1169     pub fn replace_products_list(projection: ProductsListProjection) -> Self {
   1170         Self::ReplaceProductsList(projection)
   1171     }
   1172 
   1173     pub const fn select_orders_filter(filter: OrdersFilter) -> Self {
   1174         Self::SelectOrdersFilter(filter)
   1175     }
   1176 
   1177     pub fn select_orders_fulfillment_window(
   1178         fulfillment_window_id: Option<FulfillmentWindowId>,
   1179     ) -> Self {
   1180         Self::SelectOrdersFulfillmentWindow(fulfillment_window_id)
   1181     }
   1182 
   1183     pub fn replace_orders_list(projection: OrdersListProjection) -> Self {
   1184         Self::ReplaceOrdersList(projection)
   1185     }
   1186 
   1187     pub fn replace_orders_reminders(projection: ReminderFeedProjection) -> Self {
   1188         Self::ReplaceOrdersReminders(projection)
   1189     }
   1190 
   1191     pub fn replace_reminder_log(projection: ReminderLogProjection) -> Self {
   1192         Self::ReplaceReminderLog(projection)
   1193     }
   1194 
   1195     pub fn replace_order_detail(projection: Option<OrderDetailProjection>) -> Self {
   1196         Self::ReplaceOrderDetail(projection)
   1197     }
   1198 
   1199     pub fn set_pack_day_fulfillment_window(
   1200         fulfillment_window_id: Option<FulfillmentWindowId>,
   1201     ) -> Self {
   1202         Self::SetPackDayFulfillmentWindow(fulfillment_window_id)
   1203     }
   1204 
   1205     pub fn replace_pack_day_projection(projection: PackDayProjection) -> Self {
   1206         Self::ReplacePackDayProjection(projection)
   1207     }
   1208 
   1209     pub fn begin_pack_day_export(request: PackDayExportRequest) -> Self {
   1210         Self::BeginPackDayExport(request)
   1211     }
   1212 
   1213     pub fn succeed_pack_day_export(
   1214         request: PackDayExportRequest,
   1215         bundle: PackDayExportBundle,
   1216     ) -> Self {
   1217         Self::SucceedPackDayExport { request, bundle }
   1218     }
   1219 
   1220     pub fn fail_pack_day_export(request: PackDayExportRequest, message: impl Into<String>) -> Self {
   1221         Self::FailPackDayExport {
   1222             request,
   1223             message: message.into(),
   1224         }
   1225     }
   1226 
   1227     pub const fn reset_pack_day_export() -> Self {
   1228         Self::ResetPackDayExport
   1229     }
   1230 
   1231     pub fn begin_pack_day_print(request: PackDayPrintRequest) -> Self {
   1232         Self::BeginPackDayPrint(request)
   1233     }
   1234 
   1235     pub fn succeed_pack_day_print(request: PackDayPrintRequest) -> Self {
   1236         Self::SucceedPackDayPrint(request)
   1237     }
   1238 
   1239     pub fn fail_pack_day_print(request: PackDayPrintRequest) -> Self {
   1240         Self::FailPackDayPrint(request)
   1241     }
   1242 
   1243     pub fn fail_pack_day_print_with_kind(
   1244         request: PackDayPrintRequest,
   1245         failure: PackDayPrintFailureKind,
   1246     ) -> Self {
   1247         Self::FailPackDayPrintWithKind { request, failure }
   1248     }
   1249 
   1250     pub const fn reset_pack_day_print() -> Self {
   1251         Self::ResetPackDayPrint
   1252     }
   1253 
   1254     pub fn begin_pack_day_batch_print(request: PackDayBatchPrintRequest) -> Self {
   1255         Self::BeginPackDayBatchPrint(request)
   1256     }
   1257 
   1258     pub fn succeed_pack_day_batch_print(request: PackDayBatchPrintRequest) -> Self {
   1259         Self::SucceedPackDayBatchPrint(request)
   1260     }
   1261 
   1262     pub fn fail_pack_day_batch_print(
   1263         request: PackDayBatchPrintRequest,
   1264         failed_artifact: Option<PackDayBatchPrintArtifact>,
   1265         failure: PackDayBatchPrintFailureKind,
   1266     ) -> Self {
   1267         Self::FailPackDayBatchPrint {
   1268             request,
   1269             failed_artifact,
   1270             failure,
   1271         }
   1272     }
   1273 
   1274     pub const fn reset_pack_day_batch_print() -> Self {
   1275         Self::ResetPackDayBatchPrint
   1276     }
   1277 
   1278     pub fn begin_pack_day_host_handoff(request: PackDayHostHandoffRequest) -> Self {
   1279         Self::BeginPackDayHostHandoff(request)
   1280     }
   1281 
   1282     pub fn succeed_pack_day_host_handoff(request: PackDayHostHandoffRequest) -> Self {
   1283         Self::SucceedPackDayHostHandoff(request)
   1284     }
   1285 
   1286     pub fn fail_pack_day_host_handoff(
   1287         request: PackDayHostHandoffRequest,
   1288         message: impl Into<String>,
   1289     ) -> Self {
   1290         Self::FailPackDayHostHandoff {
   1291             request,
   1292             message: message.into(),
   1293         }
   1294     }
   1295 
   1296     pub const fn reset_pack_day_host_handoff() -> Self {
   1297         Self::ResetPackDayHostHandoff
   1298     }
   1299 
   1300     pub const fn open_new_product_editor() -> Self {
   1301         Self::OpenNewProductEditor
   1302     }
   1303 
   1304     pub fn open_existing_product_editor(product_id: ProductId, draft: ProductEditorDraft) -> Self {
   1305         Self::OpenExistingProductEditor { product_id, draft }
   1306     }
   1307 
   1308     pub fn replace_product_editor_draft(draft: ProductEditorDraft) -> Self {
   1309         Self::ReplaceProductEditorDraft(draft)
   1310     }
   1311 
   1312     pub const fn close_product_editor() -> Self {
   1313         Self::CloseProductEditor
   1314     }
   1315 }
   1316 
   1317 #[derive(Clone, Debug, Eq, Error, PartialEq)]
   1318 pub enum AppStateRepositoryError {
   1319     #[error("app state repository load failed: {message}")]
   1320     Load { message: String },
   1321     #[error("app state repository save failed: {message}")]
   1322     Save { message: String },
   1323 }
   1324 
   1325 impl AppStateRepositoryError {
   1326     pub fn load(message: impl Into<String>) -> Self {
   1327         Self::Load {
   1328             message: message.into(),
   1329         }
   1330     }
   1331 
   1332     pub fn save(message: impl Into<String>) -> Self {
   1333         Self::Save {
   1334             message: message.into(),
   1335         }
   1336     }
   1337 }
   1338 
   1339 pub trait AppStateRepository {
   1340     fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError>;
   1341 
   1342     fn save_persisted_state(
   1343         &mut self,
   1344         state: &PersistedAppState,
   1345     ) -> Result<(), AppStateRepositoryError>;
   1346 }
   1347 
   1348 #[derive(Clone, Debug, Eq, PartialEq)]
   1349 pub struct InMemoryAppStateRepository {
   1350     state: PersistedAppState,
   1351 }
   1352 
   1353 impl Default for InMemoryAppStateRepository {
   1354     fn default() -> Self {
   1355         Self::new(AppShellProjection::default())
   1356     }
   1357 }
   1358 
   1359 impl InMemoryAppStateRepository {
   1360     pub fn new(projection: AppShellProjection) -> Self {
   1361         let state = PersistedAppState {
   1362             shell: PersistedShellProjection::from_shell(&projection),
   1363             ..PersistedAppState::default()
   1364         };
   1365 
   1366         Self { state }
   1367     }
   1368 
   1369     pub fn from_persisted_state(state: PersistedAppState) -> Self {
   1370         Self { state }
   1371     }
   1372 
   1373     pub fn projection(&self) -> AppShellProjection {
   1374         self.state.shell.to_shell_projection()
   1375     }
   1376 
   1377     pub fn persisted_state(&self) -> &PersistedAppState {
   1378         &self.state
   1379     }
   1380 
   1381     pub fn overwrite(&mut self, state: PersistedAppState) {
   1382         self.state = state;
   1383     }
   1384 }
   1385 
   1386 impl AppStateRepository for InMemoryAppStateRepository {
   1387     fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError> {
   1388         Ok(self.state.clone())
   1389     }
   1390 
   1391     fn save_persisted_state(
   1392         &mut self,
   1393         state: &PersistedAppState,
   1394     ) -> Result<(), AppStateRepositoryError> {
   1395         self.state = state.clone();
   1396         Ok(())
   1397     }
   1398 }
   1399 
   1400 #[derive(Clone, Debug, Eq, PartialEq)]
   1401 pub struct FileBackedAppStateRepository {
   1402     path: PathBuf,
   1403 }
   1404 
   1405 impl FileBackedAppStateRepository {
   1406     pub fn new(path: impl Into<PathBuf>) -> Self {
   1407         Self { path: path.into() }
   1408     }
   1409 
   1410     pub fn path(&self) -> &Path {
   1411         self.path.as_path()
   1412     }
   1413 
   1414     fn write_state(&self, state: &PersistedAppState) -> Result<(), AppStateRepositoryError> {
   1415         let Some(parent) = self.path.parent() else {
   1416             return Err(AppStateRepositoryError::save(
   1417                 "app state path must have a parent directory",
   1418             ));
   1419         };
   1420         fs::create_dir_all(parent)
   1421             .map_err(|error| AppStateRepositoryError::save(error.to_string()))?;
   1422         let payload = serde_json::to_vec_pretty(&PersistedAppStateEnvelope::new(state.clone()))
   1423             .map_err(|error| AppStateRepositoryError::save(error.to_string()))?;
   1424         let temporary_path = self.path.with_extension("tmp");
   1425         let _ = fs::remove_file(&temporary_path);
   1426         fs::write(&temporary_path, payload)
   1427             .map_err(|error| AppStateRepositoryError::save(error.to_string()))?;
   1428         if self.path.exists() {
   1429             fs::remove_file(&self.path)
   1430                 .map_err(|error| AppStateRepositoryError::save(error.to_string()))?;
   1431         }
   1432         fs::rename(&temporary_path, &self.path)
   1433             .map_err(|error| AppStateRepositoryError::save(error.to_string()))
   1434     }
   1435 }
   1436 
   1437 impl AppStateRepository for FileBackedAppStateRepository {
   1438     fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError> {
   1439         let contents = match fs::read_to_string(&self.path) {
   1440             Ok(contents) => contents,
   1441             Err(error) if error.kind() == ErrorKind::NotFound => {
   1442                 return Ok(PersistedAppState::default());
   1443             }
   1444             Err(error) => {
   1445                 return Err(AppStateRepositoryError::load(error.to_string()));
   1446             }
   1447         };
   1448 
   1449         let envelope = match serde_json::from_str::<PersistedAppStateEnvelope>(&contents) {
   1450             Ok(envelope) if envelope.version == APP_STATE_SCHEMA_VERSION => envelope,
   1451             Ok(_) | Err(_) => {
   1452                 let default_state = PersistedAppState::default();
   1453                 self.write_state(&default_state)?;
   1454                 return Ok(default_state);
   1455             }
   1456         };
   1457 
   1458         let sanitized = envelope.state.sanitized_for_restart();
   1459         if sanitized != envelope.state {
   1460             self.write_state(&sanitized)?;
   1461         }
   1462 
   1463         Ok(sanitized)
   1464     }
   1465 
   1466     fn save_persisted_state(
   1467         &mut self,
   1468         state: &PersistedAppState,
   1469     ) -> Result<(), AppStateRepositoryError> {
   1470         self.write_state(state)
   1471     }
   1472 }
   1473 
   1474 #[derive(Clone, Debug, Eq, PartialEq)]
   1475 pub enum AppStatePersistenceRepository {
   1476     InMemory(InMemoryAppStateRepository),
   1477     FileBacked(FileBackedAppStateRepository),
   1478 }
   1479 
   1480 impl AppStatePersistenceRepository {
   1481     pub fn in_memory() -> Self {
   1482         Self::InMemory(InMemoryAppStateRepository::default())
   1483     }
   1484 
   1485     pub fn file_backed(path: impl Into<PathBuf>) -> Self {
   1486         Self::FileBacked(FileBackedAppStateRepository::new(path))
   1487     }
   1488 }
   1489 
   1490 impl AppStateRepository for AppStatePersistenceRepository {
   1491     fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError> {
   1492         match self {
   1493             Self::InMemory(repository) => repository.load_persisted_state(),
   1494             Self::FileBacked(repository) => repository.load_persisted_state(),
   1495         }
   1496     }
   1497 
   1498     fn save_persisted_state(
   1499         &mut self,
   1500         state: &PersistedAppState,
   1501     ) -> Result<(), AppStateRepositoryError> {
   1502         match self {
   1503             Self::InMemory(repository) => repository.save_persisted_state(state),
   1504             Self::FileBacked(repository) => repository.save_persisted_state(state),
   1505         }
   1506     }
   1507 }
   1508 
   1509 #[derive(Clone, Debug, Eq, Error, PartialEq)]
   1510 pub enum AppStateStoreError {
   1511     #[error(transparent)]
   1512     Repository(#[from] AppStateRepositoryError),
   1513 }
   1514 
   1515 #[derive(Clone, Debug)]
   1516 pub struct AppStateStore<R> {
   1517     repository: R,
   1518     projection: AppProjection,
   1519     persisted_state: PersistedAppState,
   1520 }
   1521 
   1522 impl<R: AppStateRepository> AppStateStore<R> {
   1523     pub fn load(repository: R) -> Result<Self, AppStateStoreError> {
   1524         let persisted_state = repository.load_persisted_state()?;
   1525         let projection = persisted_state.to_projection();
   1526 
   1527         Ok(Self {
   1528             repository,
   1529             projection,
   1530             persisted_state,
   1531         })
   1532     }
   1533 
   1534     pub fn projection(&self) -> &AppProjection {
   1535         &self.projection
   1536     }
   1537 
   1538     pub fn shell_projection(&self) -> &AppShellProjection {
   1539         &self.projection.shell
   1540     }
   1541 
   1542     pub fn today_projection(&self) -> &TodayAgendaProjection {
   1543         &self.projection.today
   1544     }
   1545 
   1546     pub fn identity_projection(&self) -> &AppIdentityProjection {
   1547         &self.projection.identity
   1548     }
   1549 
   1550     pub fn farm_setup_projection(&self) -> &FarmSetupProjection {
   1551         &self.projection.farm_setup
   1552     }
   1553 
   1554     pub fn farm_rules_projection(&self) -> &FarmRulesProjection {
   1555         &self.projection.farm_rules
   1556     }
   1557 
   1558     pub fn farm_readiness_projection(&self) -> &FarmWorkspaceReadinessProjection {
   1559         &self.projection.farm_readiness
   1560     }
   1561 
   1562     pub fn logged_out_startup_projection(&self) -> &LoggedOutStartupProjection {
   1563         &self.projection.logged_out_startup
   1564     }
   1565 
   1566     pub fn personal_projection(&self) -> &PersonalWorkspaceProjection {
   1567         &self.projection.personal
   1568     }
   1569 
   1570     pub fn products_projection(&self) -> &ProductsScreenProjection {
   1571         &self.projection.products
   1572     }
   1573 
   1574     pub fn orders_projection(&self) -> &OrdersScreenProjection {
   1575         &self.projection.orders
   1576     }
   1577 
   1578     pub fn reminder_log_projection(&self) -> &ReminderLogProjection {
   1579         &self.projection.reminder_log
   1580     }
   1581 
   1582     pub fn pack_day_projection(&self) -> &PackDayScreenProjection {
   1583         &self.projection.pack_day
   1584     }
   1585 
   1586     pub fn home_route(&self) -> HomeRoute {
   1587         self.projection.home_route()
   1588     }
   1589 
   1590     pub fn settings_account_projection(&self) -> SettingsAccountProjection {
   1591         self.projection.identity.settings_account()
   1592     }
   1593 
   1594     pub fn startup_gate(&self) -> AppStartupGate {
   1595         self.projection.startup_gate
   1596     }
   1597 
   1598     pub fn sync_projection(&self) -> &AppSyncProjection {
   1599         &self.projection.sync
   1600     }
   1601 
   1602     pub fn repository(&self) -> &R {
   1603         &self.repository
   1604     }
   1605 
   1606     pub fn persisted_state(&self) -> &PersistedAppState {
   1607         &self.persisted_state
   1608     }
   1609 
   1610     pub fn apply(&mut self, command: AppStateCommand) -> Result<bool, AppStateStoreError> {
   1611         let mut next_projection = self.projection.clone();
   1612         if matches!(
   1613             apply_command(&mut next_projection, command),
   1614             AppStateMutation::NoChange
   1615         ) {
   1616             return Ok(false);
   1617         }
   1618 
   1619         let next_persisted_state = PersistedAppState::from_projection(&next_projection);
   1620         if next_persisted_state != self.persisted_state {
   1621             self.repository
   1622                 .save_persisted_state(&next_persisted_state)?;
   1623         }
   1624         self.persisted_state = next_persisted_state;
   1625         self.projection = next_projection;
   1626 
   1627         Ok(true)
   1628     }
   1629 
   1630     pub fn apply_in_memory(&mut self, command: AppStateCommand) -> bool {
   1631         match self.apply(command) {
   1632             Ok(changed) => changed,
   1633             Err(error) => {
   1634                 error!(target: "app_state", error = %error, "failed to persist app state");
   1635                 false
   1636             }
   1637         }
   1638     }
   1639 }
   1640 
   1641 impl AppStateStore<InMemoryAppStateRepository> {
   1642     pub fn in_memory(projection: AppShellProjection) -> Self {
   1643         let repository = InMemoryAppStateRepository::new(projection.clone());
   1644         let persisted_state = repository.persisted_state().clone();
   1645         Self {
   1646             repository,
   1647             projection: persisted_state.to_projection(),
   1648             persisted_state,
   1649         }
   1650     }
   1651 }
   1652 
   1653 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
   1654 enum AppStateMutation {
   1655     NoChange,
   1656     ShellChanged,
   1657     FarmSetupChanged,
   1658     StartupChanged,
   1659     SyncChanged,
   1660     PersonalChanged,
   1661     TodayChanged,
   1662     ProductsChanged,
   1663     OrdersChanged,
   1664     PackDayChanged,
   1665 }
   1666 
   1667 fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> AppStateMutation {
   1668     let before = projection.clone();
   1669 
   1670     match command {
   1671         AppStateCommand::SelectActiveSurface(active_surface) => {
   1672             projection.shell.select_active_surface(active_surface);
   1673             if let Some(selected_account) = projection.identity.selected_account.as_mut() {
   1674                 let selected_surface = if selected_account.farmer_activation.is_active() {
   1675                     active_surface
   1676                 } else {
   1677                     ActiveSurface::Personal
   1678                 };
   1679                 selected_account.selected_surface =
   1680                     SelectedSurfaceProjection::new(selected_surface);
   1681             }
   1682         }
   1683         AppStateCommand::SelectSection(selected_section) => {
   1684             projection.shell.select_section(selected_section);
   1685         }
   1686         AppStateCommand::SelectSettingsSection(selected_section) => {
   1687             projection.shell.select_settings_section(selected_section);
   1688         }
   1689         AppStateCommand::ShowStartupIdentityChoice => {
   1690             if projection.startup_gate == AppStartupGate::SetupRequired {
   1691                 projection.logged_out_startup.phase = LoggedOutStartupPhase::IdentityChoice;
   1692             }
   1693         }
   1694         AppStateCommand::BeginGenerateKeyStartup => {
   1695             if projection.startup_gate == AppStartupGate::SetupRequired {
   1696                 projection.logged_out_startup.phase = LoggedOutStartupPhase::GenerateKeyStarting;
   1697             }
   1698         }
   1699         AppStateCommand::ShowStartupSignerEntry => {
   1700             if projection.startup_gate == AppStartupGate::SetupRequired {
   1701                 projection.logged_out_startup.phase = LoggedOutStartupPhase::SignerEntry;
   1702             }
   1703         }
   1704         AppStateCommand::SetStartupSignerSourceInput(source_input) => {
   1705             if projection.startup_gate == AppStartupGate::SetupRequired {
   1706                 projection
   1707                     .logged_out_startup
   1708                     .signer_entry
   1709                     .set_source_input(source_input);
   1710             }
   1711         }
   1712         AppStateCommand::ResetLoggedOutStartup => {
   1713             projection.logged_out_startup = LoggedOutStartupProjection::default();
   1714         }
   1715         AppStateCommand::ReplaceIdentityProjection(identity_projection) => {
   1716             projection.identity = identity_projection;
   1717         }
   1718         AppStateCommand::ReplaceSyncProjection(sync_projection) => {
   1719             projection.sync = sync_projection;
   1720         }
   1721         AppStateCommand::ReplacePersonalProjection(personal_projection) => {
   1722             projection.personal = personal_projection;
   1723         }
   1724         AppStateCommand::ReplaceFarmSetupProjection(farm_setup_projection) => {
   1725             projection.farm_setup = farm_setup_projection;
   1726         }
   1727         AppStateCommand::ReplaceFarmRulesProjection(farm_rules_projection) => {
   1728             projection.farm_rules = farm_rules_projection;
   1729         }
   1730         AppStateCommand::SelectFarmSetupFlowStage(flow_stage) => {
   1731             projection.farm_setup_flow_stage = flow_stage;
   1732         }
   1733         AppStateCommand::SetSettingsPreference {
   1734             preference,
   1735             enabled,
   1736         } => {
   1737             projection
   1738                 .shell
   1739                 .settings
   1740                 .general
   1741                 .set_preference(preference, enabled);
   1742         }
   1743         AppStateCommand::ReplaceTodayAgenda(today_projection) => {
   1744             projection.today = today_projection;
   1745         }
   1746         AppStateCommand::SetProductsSearchQuery(search_query) => {
   1747             projection.products.query.set_search_query(search_query);
   1748         }
   1749         AppStateCommand::SelectProductsFilter(filter) => {
   1750             projection.products.query.select_filter(filter);
   1751         }
   1752         AppStateCommand::SelectProductsSort(sort) => {
   1753             projection.products.query.select_sort(sort);
   1754         }
   1755         AppStateCommand::ReplaceProductsList(products_projection) => {
   1756             projection.products.list = products_projection;
   1757         }
   1758         AppStateCommand::SelectOrdersFilter(filter) => {
   1759             projection.orders.select_filter(filter);
   1760         }
   1761         AppStateCommand::SelectOrdersFulfillmentWindow(fulfillment_window_id) => {
   1762             projection
   1763                 .orders
   1764                 .select_fulfillment_window(fulfillment_window_id);
   1765         }
   1766         AppStateCommand::ReplaceOrdersList(orders_projection) => {
   1767             projection.orders.list = orders_projection;
   1768         }
   1769         AppStateCommand::ReplaceOrdersReminders(reminders_projection) => {
   1770             projection.orders.reminders = reminders_projection;
   1771         }
   1772         AppStateCommand::ReplaceReminderLog(reminder_log_projection) => {
   1773             projection.reminder_log = reminder_log_projection;
   1774         }
   1775         AppStateCommand::ReplaceOrderDetail(order_detail_projection) => {
   1776             projection.orders.replace_detail(order_detail_projection);
   1777         }
   1778         AppStateCommand::SetPackDayFulfillmentWindow(fulfillment_window_id) => {
   1779             projection
   1780                 .pack_day
   1781                 .select_fulfillment_window(fulfillment_window_id);
   1782         }
   1783         AppStateCommand::ReplacePackDayProjection(pack_day_projection) => {
   1784             projection.pack_day.replace_projection(pack_day_projection);
   1785         }
   1786         AppStateCommand::BeginPackDayExport(request) => {
   1787             projection
   1788                 .pack_day
   1789                 .replace_export(PackDayExportProjection::running(request));
   1790         }
   1791         AppStateCommand::SucceedPackDayExport { request, bundle } => {
   1792             projection
   1793                 .pack_day
   1794                 .replace_export(PackDayExportProjection::succeeded(request, bundle));
   1795         }
   1796         AppStateCommand::FailPackDayExport { request, message } => {
   1797             projection
   1798                 .pack_day
   1799                 .replace_export(PackDayExportProjection::failed(request, message));
   1800         }
   1801         AppStateCommand::ResetPackDayExport => {
   1802             projection
   1803                 .pack_day
   1804                 .replace_export(PackDayExportProjection::default());
   1805         }
   1806         AppStateCommand::BeginPackDayPrint(request) => {
   1807             projection
   1808                 .pack_day
   1809                 .replace_print(PackDayPrintProjection::running(request));
   1810         }
   1811         AppStateCommand::SucceedPackDayPrint(request) => {
   1812             projection
   1813                 .pack_day
   1814                 .replace_print(PackDayPrintProjection::succeeded(request));
   1815         }
   1816         AppStateCommand::FailPackDayPrint(request) => {
   1817             projection
   1818                 .pack_day
   1819                 .replace_print(PackDayPrintProjection::failed(request));
   1820         }
   1821         AppStateCommand::FailPackDayPrintWithKind { request, failure } => {
   1822             projection
   1823                 .pack_day
   1824                 .replace_print(PackDayPrintProjection::failed_with_kind(request, failure));
   1825         }
   1826         AppStateCommand::ResetPackDayPrint => {
   1827             projection
   1828                 .pack_day
   1829                 .replace_print(PackDayPrintProjection::default());
   1830         }
   1831         AppStateCommand::BeginPackDayBatchPrint(request) => {
   1832             projection
   1833                 .pack_day
   1834                 .replace_batch_print(PackDayBatchPrintProjection::running(request));
   1835         }
   1836         AppStateCommand::SucceedPackDayBatchPrint(request) => {
   1837             projection
   1838                 .pack_day
   1839                 .replace_batch_print(PackDayBatchPrintProjection::succeeded(request));
   1840         }
   1841         AppStateCommand::FailPackDayBatchPrint {
   1842             request,
   1843             failed_artifact,
   1844             failure,
   1845         } => {
   1846             projection
   1847                 .pack_day
   1848                 .replace_batch_print(PackDayBatchPrintProjection::failed(
   1849                     request,
   1850                     failed_artifact,
   1851                     failure,
   1852                 ));
   1853         }
   1854         AppStateCommand::ResetPackDayBatchPrint => {
   1855             projection
   1856                 .pack_day
   1857                 .replace_batch_print(PackDayBatchPrintProjection::default());
   1858         }
   1859         AppStateCommand::BeginPackDayHostHandoff(request) => {
   1860             projection
   1861                 .pack_day
   1862                 .replace_host_handoff(PackDayHostHandoffProjection::running(request));
   1863         }
   1864         AppStateCommand::SucceedPackDayHostHandoff(request) => {
   1865             projection
   1866                 .pack_day
   1867                 .replace_host_handoff(PackDayHostHandoffProjection::succeeded(request));
   1868         }
   1869         AppStateCommand::FailPackDayHostHandoff { request, message } => {
   1870             projection
   1871                 .pack_day
   1872                 .replace_host_handoff(PackDayHostHandoffProjection::failed(request, message));
   1873         }
   1874         AppStateCommand::ResetPackDayHostHandoff => {
   1875             projection
   1876                 .pack_day
   1877                 .replace_host_handoff(PackDayHostHandoffProjection::default());
   1878         }
   1879         AppStateCommand::OpenNewProductEditor => {
   1880             projection
   1881                 .products
   1882                 .editor
   1883                 .open_new_draft(&projection.farm_readiness, &projection.farm_rules);
   1884         }
   1885         AppStateCommand::OpenExistingProductEditor { product_id, draft } => {
   1886             projection.products.editor.open_existing(
   1887                 product_id,
   1888                 draft,
   1889                 &projection.farm_readiness,
   1890                 &projection.farm_rules,
   1891             );
   1892         }
   1893         AppStateCommand::ReplaceProductEditorDraft(draft) => {
   1894             projection.products.editor.replace_draft(
   1895                 draft,
   1896                 &projection.farm_readiness,
   1897                 &projection.farm_rules,
   1898             );
   1899         }
   1900         AppStateCommand::CloseProductEditor => {
   1901             projection.products.editor.close();
   1902         }
   1903     }
   1904 
   1905     sync_projection(projection);
   1906 
   1907     if *projection == before {
   1908         AppStateMutation::NoChange
   1909     } else if projection.shell != before.shell {
   1910         AppStateMutation::ShellChanged
   1911     } else if projection.farm_setup != before.farm_setup
   1912         || projection.farm_rules != before.farm_rules
   1913         || projection.farm_readiness != before.farm_readiness
   1914         || projection.farm_setup_flow_stage != before.farm_setup_flow_stage
   1915     {
   1916         AppStateMutation::FarmSetupChanged
   1917     } else if projection.logged_out_startup != before.logged_out_startup {
   1918         AppStateMutation::StartupChanged
   1919     } else if projection.sync != before.sync {
   1920         AppStateMutation::SyncChanged
   1921     } else if projection.personal != before.personal {
   1922         AppStateMutation::PersonalChanged
   1923     } else if projection.products != before.products {
   1924         AppStateMutation::ProductsChanged
   1925     } else if projection.orders != before.orders {
   1926         AppStateMutation::OrdersChanged
   1927     } else if projection.reminder_log != before.reminder_log {
   1928         AppStateMutation::OrdersChanged
   1929     } else if projection.pack_day != before.pack_day {
   1930         AppStateMutation::PackDayChanged
   1931     } else {
   1932         AppStateMutation::TodayChanged
   1933     }
   1934 }
   1935 
   1936 fn sync_projection(projection: &mut AppProjection) {
   1937     sync_shell_to_identity(&mut projection.shell, &projection.identity);
   1938     sync_farm_setup_to_today(&mut projection.farm_setup, &projection.today);
   1939     projection.farm_readiness =
   1940         derive_farm_workspace_readiness(&projection.farm_setup, &projection.farm_rules);
   1941     sync_coarse_farm_readiness(
   1942         &mut projection.farm_setup,
   1943         &mut projection.today,
   1944         &projection.farm_readiness,
   1945     );
   1946     projection.today.setup_checklist =
   1947         derive_today_setup_checklist(&projection.farm_readiness, &projection.products.list);
   1948     sync_product_editor_publish_blockers(
   1949         &mut projection.products.editor,
   1950         &projection.farm_readiness,
   1951         &projection.farm_rules,
   1952     );
   1953     projection.startup_gate = projection.identity.startup_gate();
   1954     projection.personal.entry = projection.identity.personal_entry();
   1955     sync_logged_out_startup(&mut projection.logged_out_startup, projection.startup_gate);
   1956     sync_farm_setup_flow_stage(
   1957         &mut projection.farm_setup_flow_stage,
   1958         projection.startup_gate,
   1959         projection.farm_setup.has_saved_farm(),
   1960     );
   1961 }
   1962 
   1963 fn sync_shell_to_identity(shell: &mut AppShellProjection, identity: &AppIdentityProjection) {
   1964     match identity.startup_gate() {
   1965         AppStartupGate::Blocked | AppStartupGate::SetupRequired => {
   1966             shell.active_surface = ActiveSurface::Personal;
   1967             if matches!(shell.selected_section, ShellSection::Farmer(_)) {
   1968                 shell.selected_section = ShellSection::Home;
   1969             }
   1970         }
   1971         AppStartupGate::Personal => {
   1972             shell.active_surface = ActiveSurface::Personal;
   1973             if matches!(shell.selected_section, ShellSection::Farmer(_)) {
   1974                 shell.selected_section = ShellSection::default_for_surface(ActiveSurface::Personal);
   1975             }
   1976         }
   1977         AppStartupGate::Farmer => {
   1978             shell.active_surface = ActiveSurface::Farmer;
   1979             if matches!(
   1980                 shell.selected_section,
   1981                 ShellSection::Home | ShellSection::Personal(_)
   1982             ) {
   1983                 shell.selected_section = ShellSection::default_for_surface(ActiveSurface::Farmer);
   1984             }
   1985         }
   1986     }
   1987 }
   1988 
   1989 fn sync_farm_setup_to_today(farm_setup: &mut FarmSetupProjection, today: &TodayAgendaProjection) {
   1990     if let Some(saved_farm) = today.farm.clone()
   1991         && !farm_setup.has_saved_farm()
   1992     {
   1993         *farm_setup = FarmSetupProjection::from_saved_farm(saved_farm);
   1994     }
   1995 }
   1996 
   1997 fn sync_farm_setup_flow_stage(
   1998     flow_stage: &mut FarmSetupFlowStage,
   1999     startup_gate: AppStartupGate,
   2000     has_saved_farm: bool,
   2001 ) {
   2002     if startup_gate != AppStartupGate::Farmer || has_saved_farm {
   2003         *flow_stage = FarmSetupFlowStage::Onboarding;
   2004     }
   2005 }
   2006 
   2007 fn sync_logged_out_startup(
   2008     logged_out_startup: &mut LoggedOutStartupProjection,
   2009     startup_gate: AppStartupGate,
   2010 ) {
   2011     if startup_gate != AppStartupGate::SetupRequired {
   2012         *logged_out_startup = LoggedOutStartupProjection::default();
   2013     }
   2014 }
   2015 
   2016 pub fn derive_farm_workspace_readiness(
   2017     farm_setup: &FarmSetupProjection,
   2018     farm_rules: &FarmRulesProjection,
   2019 ) -> FarmWorkspaceReadinessProjection {
   2020     if !farm_setup.has_saved_farm() {
   2021         return FarmWorkspaceReadinessProjection {
   2022             has_saved_farm: false,
   2023             status: if farm_setup.readiness == FarmSetupReadiness::NotStarted {
   2024                 FarmWorkspaceStatus::NoFarm
   2025             } else {
   2026                 FarmWorkspaceStatus::SetupRequired
   2027             },
   2028             setup_blockers: farm_setup.blockers.clone(),
   2029             rules_blockers: Vec::new(),
   2030             timing_conflicts: Vec::new(),
   2031         };
   2032     }
   2033 
   2034     let status = if farm_rules.is_ready() {
   2035         FarmWorkspaceStatus::Ready
   2036     } else {
   2037         FarmWorkspaceStatus::SetupRequired
   2038     };
   2039 
   2040     FarmWorkspaceReadinessProjection {
   2041         has_saved_farm: true,
   2042         status,
   2043         setup_blockers: Vec::new(),
   2044         rules_blockers: farm_rules.readiness.blockers.clone(),
   2045         timing_conflicts: farm_rules.readiness.timing_conflicts.clone(),
   2046     }
   2047 }
   2048 
   2049 pub fn derive_today_setup_checklist(
   2050     farm_readiness: &FarmWorkspaceReadinessProjection,
   2051     products: &ProductsListProjection,
   2052 ) -> Vec<TodaySetupTask> {
   2053     if !farm_readiness.has_saved_farm {
   2054         return Vec::new();
   2055     }
   2056 
   2057     vec![
   2058         TodaySetupTask {
   2059             kind: TodaySetupTaskKind::CompleteFarmProfile,
   2060             is_complete: !farm_readiness
   2061                 .has_rules_blocker(FarmReadinessBlocker::MissingProfileBasics),
   2062         },
   2063         TodaySetupTask {
   2064             kind: TodaySetupTaskKind::AddPickupLocation,
   2065             is_complete: !farm_readiness
   2066                 .has_rules_blocker(FarmReadinessBlocker::MissingPickupLocation),
   2067         },
   2068         TodaySetupTask {
   2069             kind: TodaySetupTaskKind::AddOperatingRules,
   2070             is_complete: !farm_readiness
   2071                 .has_rules_blocker(FarmReadinessBlocker::MissingOperatingRules),
   2072         },
   2073         TodaySetupTask {
   2074             kind: TodaySetupTaskKind::AddFulfillmentWindow,
   2075             is_complete: !farm_readiness
   2076                 .has_rules_blocker(FarmReadinessBlocker::MissingFulfillmentWindow),
   2077         },
   2078         TodaySetupTask {
   2079             kind: TodaySetupTaskKind::ResolveAvailabilityConflicts,
   2080             is_complete: farm_readiness.timing_conflicts.is_empty(),
   2081         },
   2082         TodaySetupTask {
   2083             kind: TodaySetupTaskKind::PublishProduct,
   2084             is_complete: products.summary.live_products > 0,
   2085         },
   2086     ]
   2087 }
   2088 
   2089 pub fn derive_product_publish_blockers(
   2090     draft: &ProductEditorDraft,
   2091     farm_readiness: &FarmWorkspaceReadinessProjection,
   2092     farm_rules: &FarmRulesProjection,
   2093 ) -> Vec<ProductPublishBlocker> {
   2094     let mut blockers = draft.publish_blockers();
   2095 
   2096     if draft.availability_window_id.is_some()
   2097         && !product_availability_window_exists(draft, farm_rules)
   2098     {
   2099         push_unique_product_blocker(&mut blockers, ProductPublishBlocker::AttachAvailability);
   2100     }
   2101 
   2102     if farm_readiness.has_saved_farm {
   2103         replace_availability_blocker(&mut blockers, farm_readiness);
   2104 
   2105         if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingProfileBasics) {
   2106             push_unique_product_blocker(&mut blockers, ProductPublishBlocker::CompleteFarmProfile);
   2107         }
   2108 
   2109         if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingPickupLocation) {
   2110             push_unique_product_blocker(&mut blockers, ProductPublishBlocker::AddPickupLocation);
   2111         }
   2112 
   2113         if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingOperatingRules) {
   2114             push_unique_product_blocker(&mut blockers, ProductPublishBlocker::AddOperatingRules);
   2115         }
   2116 
   2117         if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingFulfillmentWindow) {
   2118             push_unique_product_blocker(&mut blockers, ProductPublishBlocker::AddFulfillmentWindow);
   2119         }
   2120 
   2121         if !farm_readiness.timing_conflicts.is_empty() {
   2122             push_unique_product_blocker(
   2123                 &mut blockers,
   2124                 ProductPublishBlocker::ResolveAvailabilityConflicts,
   2125             );
   2126         }
   2127     }
   2128 
   2129     blockers
   2130 }
   2131 
   2132 fn product_availability_window_exists(
   2133     draft: &ProductEditorDraft,
   2134     farm_rules: &FarmRulesProjection,
   2135 ) -> bool {
   2136     draft.availability_window_id.is_some_and(|window_id| {
   2137         farm_rules
   2138             .fulfillment_windows
   2139             .iter()
   2140             .any(|window| window.fulfillment_window_id == window_id)
   2141     })
   2142 }
   2143 
   2144 fn sync_coarse_farm_readiness(
   2145     farm_setup: &mut FarmSetupProjection,
   2146     today: &mut TodayAgendaProjection,
   2147     farm_readiness: &FarmWorkspaceReadinessProjection,
   2148 ) {
   2149     let Some(coarse_readiness) = farm_readiness.coarse_readiness() else {
   2150         return;
   2151     };
   2152 
   2153     if let Some(saved_farm) = farm_setup.saved_farm.as_mut() {
   2154         saved_farm.readiness = coarse_readiness;
   2155     }
   2156 
   2157     if let Some(saved_farm) = today.farm.as_mut() {
   2158         saved_farm.readiness = coarse_readiness;
   2159     }
   2160 }
   2161 
   2162 fn sync_product_editor_publish_blockers(
   2163     editor: &mut ProductEditorState,
   2164     farm_readiness: &FarmWorkspaceReadinessProjection,
   2165     farm_rules: &FarmRulesProjection,
   2166 ) {
   2167     if let ProductEditorState::Open(session) = editor {
   2168         session.publish_blockers =
   2169             derive_product_publish_blockers(&session.draft, farm_readiness, farm_rules);
   2170     }
   2171 }
   2172 
   2173 fn replace_availability_blocker(
   2174     blockers: &mut [ProductPublishBlocker],
   2175     farm_readiness: &FarmWorkspaceReadinessProjection,
   2176 ) {
   2177     for blocker in blockers.iter_mut() {
   2178         if *blocker != ProductPublishBlocker::AttachAvailability {
   2179             continue;
   2180         }
   2181 
   2182         *blocker = if !farm_readiness.timing_conflicts.is_empty() {
   2183             ProductPublishBlocker::ResolveAvailabilityConflicts
   2184         } else if farm_readiness.has_rules_blocker(FarmReadinessBlocker::MissingFulfillmentWindow) {
   2185             ProductPublishBlocker::AddFulfillmentWindow
   2186         } else {
   2187             ProductPublishBlocker::AttachAvailability
   2188         };
   2189     }
   2190 }
   2191 
   2192 fn push_unique_product_blocker(
   2193     blockers: &mut Vec<ProductPublishBlocker>,
   2194     blocker: ProductPublishBlocker,
   2195 ) {
   2196     if !blockers.contains(&blocker) {
   2197         blockers.push(blocker);
   2198     }
   2199 }
   2200 
   2201 pub fn derive_sync_projection(
   2202     checkpoint: &SyncCheckpointStatus,
   2203     conflicts: &[SyncConflict],
   2204 ) -> AppSyncProjection {
   2205     let conflict_status = SyncConflictStatus::from_conflicts(conflicts);
   2206 
   2207     AppSyncProjection {
   2208         run_status: derive_sync_run_status(checkpoint, &conflict_status),
   2209         checkpoint: checkpoint.clone(),
   2210         conflict_status,
   2211     }
   2212 }
   2213 
   2214 pub fn derive_sync_run_status(
   2215     checkpoint: &SyncCheckpointStatus,
   2216     conflict_status: &SyncConflictStatus,
   2217 ) -> AppSyncRunStatus {
   2218     if checkpoint.is_syncing() {
   2219         AppSyncRunStatus::Syncing
   2220     } else if checkpoint.is_failed() {
   2221         AppSyncRunStatus::Failed
   2222     } else if conflict_status.requires_attention() {
   2223         AppSyncRunStatus::Conflicted
   2224     } else if checkpoint.state == SyncCheckpointState::Current {
   2225         AppSyncRunStatus::Succeeded
   2226     } else {
   2227         AppSyncRunStatus::Idle
   2228     }
   2229 }
   2230 
   2231 #[cfg(test)]
   2232 mod tests {
   2233     use super::{
   2234         AppProjection, AppShellProjection, AppStateCommand, AppStateRepository,
   2235         AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute,
   2236         InMemoryAppStateRepository, OrdersScreenProjection, PackDayBatchPrintProjection,
   2237         PackDayBatchPrintRequest, PackDayExportProjection, PackDayExportRequest,
   2238         PackDayHostHandoffProjection, PackDayHostHandoffRequest, PackDayPrintProjection,
   2239         PackDayPrintRequest, PackDayScreenProjection, PersistedAppState, ProductEditorState,
   2240         ProductsScreenProjection, ProductsScreenQueryState, SettingsPreference,
   2241         derive_sync_projection, derive_sync_run_status,
   2242     };
   2243     use radroots_app_sync::{
   2244         AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus,
   2245         SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity,
   2246         SyncConflictStatus,
   2247     };
   2248     use radroots_app_view::{
   2249         AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate,
   2250         FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness,
   2251         FarmRulesProjection, FarmRulesReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary,
   2252         FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord,
   2253         LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow,
   2254         OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter,
   2255         OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState,
   2256         PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayBatchPrintStatus,
   2257         PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle,
   2258         PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind,
   2259         PackDayHostHandoffStatus, PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind,
   2260         PackDayPrintLabelStock, PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection,
   2261         PackDayRosterRow, PackDayScreenQueryState, PersonalEntryState, PersonalSection,
   2262         PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductId,
   2263         ProductPricePresentation, ProductPublishBlocker, ProductsFilter, ProductsListProjection,
   2264         ProductsSort, ReminderDeliveryState, ReminderFeedProjection, ReminderKind,
   2265         ReminderLogEntryProjection, ReminderLogProjection, SelectedAccountProjection,
   2266         SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection,
   2267         TodaySetupTask, TodaySetupTaskKind, TradeEconomicsProjection, TradeWorkflowProjection,
   2268     };
   2269 
   2270     struct FailingRepository;
   2271 
   2272     impl AppStateRepository for FailingRepository {
   2273         fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError> {
   2274             Ok(PersistedAppState::default())
   2275         }
   2276 
   2277         fn save_persisted_state(
   2278             &mut self,
   2279             _: &PersistedAppState,
   2280         ) -> Result<(), AppStateRepositoryError> {
   2281             Err(AppStateRepositoryError::save("disk unavailable"))
   2282         }
   2283     }
   2284 
   2285     fn ready_identity(surface: ActiveSurface) -> AppIdentityProjection {
   2286         AppIdentityProjection::ready(
   2287             Vec::new(),
   2288             SelectedAccountProjection::new(
   2289                 AccountSummary {
   2290                     account_id: "acct_surface".to_owned(),
   2291                     npub: "npub1surface".to_owned(),
   2292                     label: Some("North field".to_owned()),
   2293                     custody: AccountCustody::LocalManaged,
   2294                 },
   2295                 SelectedSurfaceProjection::new(surface),
   2296                 FarmerActivationProjection::active(FarmId::new()),
   2297             ),
   2298         )
   2299     }
   2300 
   2301     fn sample_pack_day_export_request(
   2302         fulfillment_window_id: FulfillmentWindowId,
   2303     ) -> PackDayExportRequest {
   2304         PackDayExportRequest::for_fulfillment_window(fulfillment_window_id)
   2305     }
   2306 
   2307     fn sample_pack_day_host_handoff_request(
   2308         fulfillment_window_id: FulfillmentWindowId,
   2309         kind: PackDayHostHandoffKind,
   2310     ) -> PackDayHostHandoffRequest {
   2311         let bundle = sample_pack_day_export_bundle(fulfillment_window_id);
   2312         PackDayHostHandoffRequest::for_bundle(kind, &bundle)
   2313     }
   2314 
   2315     fn sample_pack_day_print_request(
   2316         fulfillment_window_id: FulfillmentWindowId,
   2317         kind: PackDayPrintKind,
   2318     ) -> PackDayPrintRequest {
   2319         let bundle = sample_pack_day_export_bundle(fulfillment_window_id);
   2320         PackDayPrintRequest::for_bundle(kind, &bundle)
   2321     }
   2322 
   2323     fn sample_pack_day_batch_print_request(
   2324         fulfillment_window_id: FulfillmentWindowId,
   2325     ) -> PackDayBatchPrintRequest {
   2326         let bundle = sample_pack_day_export_bundle(fulfillment_window_id);
   2327         PackDayBatchPrintRequest::for_bundle(&bundle)
   2328     }
   2329 
   2330     fn sample_pack_day_export_bundle(
   2331         fulfillment_window_id: FulfillmentWindowId,
   2332     ) -> PackDayExportBundle {
   2333         PackDayExportBundle {
   2334             fulfillment_window_id,
   2335             export_instance_id: PackDayExportInstanceId::new(),
   2336             generated_at_utc: "2026-04-23T15:00:00Z".to_owned(),
   2337             bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(),
   2338             artifacts: vec![
   2339                 PackDayExportArtifact {
   2340                     kind: PackDayExportArtifactKind::PackSheet,
   2341                     relative_path: "pack_sheet.txt".to_owned(),
   2342                 },
   2343                 PackDayExportArtifact {
   2344                     kind: PackDayExportArtifactKind::PickupRoster,
   2345                     relative_path: "pickup_roster.txt".to_owned(),
   2346                 },
   2347                 PackDayExportArtifact {
   2348                     kind: PackDayExportArtifactKind::CustomerLabels,
   2349                     relative_path: "customer_labels.txt".to_owned(),
   2350                 },
   2351             ],
   2352         }
   2353     }
   2354 
   2355     #[test]
   2356     fn default_projection_starts_on_personal_setup_gate() {
   2357         let projection = AppProjection::default();
   2358 
   2359         assert_eq!(projection.shell.active_surface, ActiveSurface::Personal);
   2360         assert_eq!(projection.shell.selected_section, ShellSection::Home);
   2361         assert_eq!(projection.identity, AppIdentityProjection::default());
   2362         assert_eq!(projection.startup_gate, AppStartupGate::SetupRequired);
   2363         assert_eq!(projection.sync, AppSyncProjection::default());
   2364         assert_eq!(
   2365             projection.logged_out_startup,
   2366             LoggedOutStartupProjection::default()
   2367         );
   2368         assert_eq!(
   2369             projection.shell.settings.selected_section,
   2370             SettingsSection::Account
   2371         );
   2372         assert!(projection.shell.settings.general.allow_relay_connections);
   2373         assert!(projection.shell.settings.general.use_media_servers);
   2374         assert!(projection.shell.settings.general.use_nip05);
   2375         assert!(!projection.shell.settings.general.launch_at_login);
   2376         assert_eq!(projection.today, TodayAgendaProjection::default());
   2377         assert_eq!(projection.products, ProductsScreenProjection::default());
   2378         assert_eq!(projection.orders, OrdersScreenProjection::default());
   2379         assert_eq!(projection.pack_day, PackDayScreenProjection::default());
   2380         assert_eq!(projection.personal.entry.state, PersonalEntryState::Guest);
   2381         assert_eq!(projection.farm_setup, FarmSetupProjection::default());
   2382         assert_eq!(
   2383             projection.farm_setup_flow_stage,
   2384             FarmSetupFlowStage::Onboarding
   2385         );
   2386         assert_eq!(projection.home_route(), HomeRoute::SetupRequired);
   2387     }
   2388 
   2389     #[test]
   2390     fn load_uses_repository_projection() {
   2391         let repository = InMemoryAppStateRepository::new(AppShellProjection::for_settings(
   2392             ActiveSurface::Farmer,
   2393             SettingsSection::About,
   2394         ));
   2395         let store = AppStateStore::load(repository).expect("in-memory repository should load");
   2396 
   2397         assert_eq!(
   2398             store.projection().shell.active_surface,
   2399             ActiveSurface::Personal
   2400         );
   2401         assert_eq!(
   2402             store.projection().shell.selected_section,
   2403             ShellSection::Settings(SettingsSection::About)
   2404         );
   2405         assert_eq!(
   2406             store.projection().shell.settings.selected_section,
   2407             SettingsSection::About
   2408         );
   2409         assert_eq!(store.startup_gate(), AppStartupGate::SetupRequired);
   2410         assert_eq!(store.sync_projection(), &AppSyncProjection::default());
   2411         assert_eq!(
   2412             store.logged_out_startup_projection(),
   2413             &LoggedOutStartupProjection::default()
   2414         );
   2415         assert_eq!(store.projection().today, TodayAgendaProjection::default());
   2416         assert_eq!(
   2417             store.projection().products,
   2418             ProductsScreenProjection::default()
   2419         );
   2420         assert_eq!(store.projection().orders, OrdersScreenProjection::default());
   2421         assert_eq!(
   2422             store.projection().pack_day,
   2423             PackDayScreenProjection::default()
   2424         );
   2425         assert_eq!(
   2426             store.personal_projection().entry.state,
   2427             PersonalEntryState::Guest
   2428         );
   2429         assert_eq!(store.home_route(), HomeRoute::SetupRequired);
   2430     }
   2431 
   2432     #[test]
   2433     fn products_query_defaults_and_refreshes_are_local_app_state() {
   2434         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   2435             .expect("in-memory repository should load");
   2436         let products_list = ProductsListProjection {
   2437             summary: radroots_app_view::ProductsListSummary {
   2438                 total_products: 2,
   2439                 live_products: 1,
   2440                 draft_products: 1,
   2441                 need_attention_products: 1,
   2442             },
   2443             rows: Vec::new(),
   2444         };
   2445 
   2446         assert_eq!(
   2447             store.projection().products.query,
   2448             ProductsScreenQueryState::default()
   2449         );
   2450 
   2451         assert_eq!(
   2452             store.apply(AppStateCommand::set_products_search_query("pea")),
   2453             Ok(true)
   2454         );
   2455         assert_eq!(
   2456             store.apply(AppStateCommand::select_products_filter(
   2457                 ProductsFilter::NeedAttention,
   2458             )),
   2459             Ok(true)
   2460         );
   2461         assert_eq!(
   2462             store.apply(AppStateCommand::select_products_sort(ProductsSort::Name)),
   2463             Ok(true)
   2464         );
   2465         assert_eq!(
   2466             store.apply(AppStateCommand::replace_products_list(
   2467                 products_list.clone()
   2468             )),
   2469             Ok(true)
   2470         );
   2471         assert_eq!(
   2472             store.projection().products.query,
   2473             ProductsScreenQueryState::new("pea", ProductsFilter::NeedAttention, ProductsSort::Name)
   2474         );
   2475         assert_eq!(store.projection().products.list, products_list);
   2476         assert_eq!(
   2477             store.repository().projection(),
   2478             AppShellProjection::default()
   2479         );
   2480         assert_eq!(
   2481             store.repository().persisted_state().seller.products_query,
   2482             ProductsScreenQueryState::new("pea", ProductsFilter::NeedAttention, ProductsSort::Name)
   2483         );
   2484     }
   2485 
   2486     #[test]
   2487     fn orders_and_pack_day_queries_refresh_as_local_app_state() {
   2488         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   2489             .expect("in-memory repository should load");
   2490         let farm_id = FarmId::new();
   2491         let fulfillment_window_id = FulfillmentWindowId::new();
   2492         let order_id = OrderId::new();
   2493         let order_economics = TradeEconomicsProjection {
   2494             subtotal_minor_units: Some(1300),
   2495             total_minor_units: Some(1300),
   2496             currency_code: Some("USD".to_owned()),
   2497             ..TradeEconomicsProjection::default()
   2498         };
   2499         let orders_list = OrdersListProjection {
   2500             summary: OrdersListSummary {
   2501                 total_orders: 2,
   2502                 needs_action_orders: 1,
   2503                 scheduled_orders: 1,
   2504                 packed_orders: 0,
   2505             },
   2506             rows: vec![OrdersListRow {
   2507                 order_id,
   2508                 farm_id,
   2509                 fulfillment_window_id: Some(fulfillment_window_id),
   2510                 order_number: "R-100".to_owned(),
   2511                 customer_display_name: "Casey".to_owned(),
   2512                 fulfillment_window_label: Some("Friday pickup".to_owned()),
   2513                 pickup_location_label: Some("North barn".to_owned()),
   2514                 status: OrderStatus::NeedsAction,
   2515                 workflow: TradeWorkflowProjection::from_order_status(
   2516                     order_id,
   2517                     OrderStatus::NeedsAction,
   2518                 ),
   2519                 primary_action: Some(OrderPrimaryAction::Review),
   2520             }],
   2521         };
   2522         let order_detail = OrderDetailProjection {
   2523             order_id,
   2524             farm_id,
   2525             order_number: "R-100".to_owned(),
   2526             customer_display_name: "Casey".to_owned(),
   2527             status: OrderStatus::NeedsAction,
   2528             fulfillment_window_id: Some(fulfillment_window_id),
   2529             fulfillment_window_label: Some("Friday pickup".to_owned()),
   2530             pickup_location_label: Some("North barn".to_owned()),
   2531             items: vec![OrderDetailItemRow {
   2532                 title: "Salad mix".to_owned(),
   2533                 quantity_display: "2 bags".to_owned(),
   2534                 unit_price: Some(ProductPricePresentation {
   2535                     amount_minor_units: 650,
   2536                     currency_code: "USD".to_owned(),
   2537                     unit_label: "bag".to_owned(),
   2538                 }),
   2539                 line_total_minor_units: Some(1300),
   2540             }],
   2541             economics: order_economics.clone(),
   2542             workflow: TradeWorkflowProjection::from_order_status(
   2543                 order_id,
   2544                 OrderStatus::NeedsAction,
   2545             )
   2546             .with_economics(order_economics),
   2547             validation_receipts: Vec::new(),
   2548             primary_action: Some(OrderPrimaryAction::Review),
   2549         };
   2550         let orders_reminders = ReminderFeedProjection {
   2551             items: vec![radroots_app_view::ReminderDeadlineProjection {
   2552                 reminder_id: radroots_app_view::ReminderId::new(),
   2553                 farm_id,
   2554                 order_id: Some(order_id),
   2555                 fulfillment_window_id: Some(fulfillment_window_id),
   2556                 kind: radroots_app_view::ReminderKind::OrderAction,
   2557                 surface: radroots_app_view::ReminderSurface::Orders,
   2558                 urgency: radroots_app_view::ReminderUrgency::DueSoon,
   2559                 title: "review order".to_owned(),
   2560                 detail: "Casey still needs confirmation.".to_owned(),
   2561                 deadline_at: "2026-04-18T15:00:00Z".to_owned(),
   2562                 action_label: Some("Review".to_owned()),
   2563                 delivery_state: radroots_app_view::ReminderDeliveryState::Scheduled,
   2564             }],
   2565         };
   2566         let reminder_log = ReminderLogProjection {
   2567             entries: vec![ReminderLogEntryProjection {
   2568                 reminder_id: orders_reminders.items[0].reminder_id,
   2569                 kind: ReminderKind::OrderAction,
   2570                 title: "review order".to_owned(),
   2571                 recorded_at: "2026-04-18T14:30:00Z".to_owned(),
   2572                 delivery_state: ReminderDeliveryState::Presented,
   2573                 detail: Some("Casey still needs confirmation.".to_owned()),
   2574             }],
   2575         };
   2576         let pack_day = PackDayProjection {
   2577             fulfillment_window: Some(radroots_app_view::FulfillmentWindowSummary {
   2578                 fulfillment_window_id,
   2579                 farm_id,
   2580                 starts_at: "2026-04-18T16:00:00Z".to_owned(),
   2581                 ends_at: "2026-04-18T18:00:00Z".to_owned(),
   2582             }),
   2583             totals_by_product: vec![PackDayProductTotalRow {
   2584                 title: "Salad mix".to_owned(),
   2585                 quantity_display: "2 bags".to_owned(),
   2586             }],
   2587             pack_list: vec![PackDayPackListRow {
   2588                 title: "Salad mix".to_owned(),
   2589                 quantity_display: "Casey: 2 bags".to_owned(),
   2590             }],
   2591             pickup_roster: vec![PackDayRosterRow {
   2592                 order_id,
   2593                 order_number: "R-100".to_owned(),
   2594                 customer_display_name: "Casey".to_owned(),
   2595             }],
   2596             reminders: ReminderFeedProjection::default(),
   2597         };
   2598 
   2599         assert_eq!(
   2600             store.projection().orders.query,
   2601             OrdersScreenQueryState::default()
   2602         );
   2603         assert_eq!(
   2604             store.projection().pack_day.query,
   2605             PackDayScreenQueryState::default()
   2606         );
   2607 
   2608         assert_eq!(
   2609             store.apply(AppStateCommand::select_orders_filter(OrdersFilter::Packed)),
   2610             Ok(true)
   2611         );
   2612         assert_eq!(
   2613             store.apply(AppStateCommand::select_orders_fulfillment_window(Some(
   2614                 fulfillment_window_id,
   2615             ))),
   2616             Ok(true)
   2617         );
   2618         assert_eq!(
   2619             store.apply(AppStateCommand::replace_orders_list(orders_list.clone())),
   2620             Ok(true)
   2621         );
   2622         assert_eq!(
   2623             store.apply(AppStateCommand::replace_orders_reminders(
   2624                 orders_reminders.clone()
   2625             )),
   2626             Ok(true)
   2627         );
   2628         assert_eq!(
   2629             store.apply(AppStateCommand::replace_reminder_log(reminder_log.clone())),
   2630             Ok(true)
   2631         );
   2632         assert_eq!(
   2633             store.apply(AppStateCommand::replace_order_detail(Some(
   2634                 order_detail.clone()
   2635             ))),
   2636             Ok(true)
   2637         );
   2638         assert_eq!(
   2639             store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some(
   2640                 fulfillment_window_id,
   2641             ))),
   2642             Ok(true)
   2643         );
   2644         assert_eq!(
   2645             store.apply(AppStateCommand::replace_pack_day_projection(
   2646                 pack_day.clone()
   2647             )),
   2648             Ok(true)
   2649         );
   2650         assert_eq!(
   2651             store.projection().orders.query,
   2652             OrdersScreenQueryState {
   2653                 filter: OrdersFilter::Packed,
   2654                 fulfillment_window_id: Some(fulfillment_window_id),
   2655             }
   2656         );
   2657         assert_eq!(store.projection().orders.list, orders_list);
   2658         assert_eq!(store.projection().orders.reminders, orders_reminders);
   2659         assert_eq!(store.projection().reminder_log, reminder_log);
   2660         assert_eq!(store.projection().orders.detail, Some(order_detail));
   2661         assert_eq!(
   2662             store.projection().pack_day.query,
   2663             PackDayScreenQueryState {
   2664                 fulfillment_window_id: Some(fulfillment_window_id),
   2665             }
   2666         );
   2667         assert_eq!(store.projection().pack_day.projection, pack_day);
   2668         assert_eq!(
   2669             store.apply(AppStateCommand::select_orders_filter(
   2670                 OrdersFilter::NeedsAction
   2671             )),
   2672             Ok(true)
   2673         );
   2674         assert_eq!(store.projection().orders.detail, None);
   2675         assert_eq!(
   2676             store.repository().projection(),
   2677             AppShellProjection::default()
   2678         );
   2679         assert_eq!(
   2680             store.repository().persisted_state().seller.orders_query,
   2681             OrdersScreenQueryState {
   2682                 filter: OrdersFilter::NeedsAction,
   2683                 fulfillment_window_id: Some(fulfillment_window_id),
   2684             }
   2685         );
   2686         assert_eq!(
   2687             store
   2688                 .repository()
   2689                 .persisted_state()
   2690                 .seller
   2691                 .order_detail_order_id,
   2692             None
   2693         );
   2694         assert_eq!(
   2695             store.repository().persisted_state().seller.pack_day_query,
   2696             PackDayScreenQueryState {
   2697                 fulfillment_window_id: Some(fulfillment_window_id),
   2698             }
   2699         );
   2700         assert_eq!(
   2701             store.projection().pack_day.export,
   2702             PackDayExportProjection::default()
   2703         );
   2704     }
   2705 
   2706     #[test]
   2707     fn pack_day_export_and_host_handoff_projections_default_to_idle() {
   2708         assert_eq!(
   2709             PackDayScreenProjection::default().export,
   2710             PackDayExportProjection {
   2711                 status: PackDayExportStatus::Idle,
   2712                 request: None,
   2713                 bundle: None,
   2714                 error_message: None,
   2715             }
   2716         );
   2717         assert_eq!(
   2718             PackDayScreenProjection::default().print,
   2719             PackDayPrintProjection {
   2720                 status: PackDayPrintStatus::Idle,
   2721                 request: None,
   2722                 failure: None,
   2723             }
   2724         );
   2725         assert_eq!(
   2726             PackDayScreenProjection::default().batch_print,
   2727             PackDayBatchPrintProjection {
   2728                 status: PackDayBatchPrintStatus::Idle,
   2729                 request: None,
   2730                 failed_artifact: None,
   2731                 failure: None,
   2732             }
   2733         );
   2734         assert_eq!(
   2735             PackDayScreenProjection::default().host_handoff,
   2736             PackDayHostHandoffProjection {
   2737                 status: PackDayHostHandoffStatus::Idle,
   2738                 request: None,
   2739                 error_message: None,
   2740             }
   2741         );
   2742     }
   2743 
   2744     #[test]
   2745     fn pack_day_export_state_is_restart_ephemeral_and_skips_persistence() {
   2746         let mut store =
   2747             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   2748         let fulfillment_window_id = FulfillmentWindowId::new();
   2749         let request = sample_pack_day_export_request(fulfillment_window_id);
   2750         let bundle = sample_pack_day_export_bundle(fulfillment_window_id);
   2751 
   2752         assert_eq!(
   2753             store.apply(AppStateCommand::begin_pack_day_export(request.clone())),
   2754             Ok(true)
   2755         );
   2756         assert_eq!(
   2757             store.pack_day_projection().export,
   2758             PackDayExportProjection::running(request.clone())
   2759         );
   2760         assert_eq!(
   2761             store.persisted_state().seller.pack_day_query,
   2762             PackDayScreenQueryState::default()
   2763         );
   2764 
   2765         assert_eq!(
   2766             store.apply(AppStateCommand::succeed_pack_day_export(
   2767                 request.clone(),
   2768                 bundle.clone(),
   2769             )),
   2770             Ok(true)
   2771         );
   2772         assert_eq!(
   2773             store.pack_day_projection().export,
   2774             PackDayExportProjection::succeeded(request.clone(), bundle)
   2775         );
   2776 
   2777         assert_eq!(
   2778             store.apply(AppStateCommand::fail_pack_day_export(
   2779                 request.clone(),
   2780                 "disk unavailable",
   2781             )),
   2782             Ok(true)
   2783         );
   2784         assert_eq!(
   2785             store.pack_day_projection().export,
   2786             PackDayExportProjection::failed(request, "disk unavailable")
   2787         );
   2788 
   2789         assert_eq!(
   2790             store.apply(AppStateCommand::reset_pack_day_export()),
   2791             Ok(true)
   2792         );
   2793         assert_eq!(
   2794             store.pack_day_projection().export,
   2795             PackDayExportProjection::default()
   2796         );
   2797     }
   2798 
   2799     #[test]
   2800     fn pack_day_print_state_is_restart_ephemeral_and_skips_persistence() {
   2801         let mut store =
   2802             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   2803         let fulfillment_window_id = FulfillmentWindowId::new();
   2804         let request = sample_pack_day_print_request(
   2805             fulfillment_window_id,
   2806             PackDayPrintKind::PrintCustomerLabels,
   2807         );
   2808 
   2809         assert_eq!(
   2810             request.label_stock,
   2811             Some(PackDayPrintLabelStock::Avery5160Letter30Up)
   2812         );
   2813         assert_eq!(
   2814             store.apply(AppStateCommand::begin_pack_day_print(request.clone())),
   2815             Ok(true)
   2816         );
   2817         assert_eq!(
   2818             store.pack_day_projection().print,
   2819             PackDayPrintProjection::running(request.clone())
   2820         );
   2821         assert_eq!(
   2822             store.persisted_state().seller.pack_day_query,
   2823             PackDayScreenQueryState::default()
   2824         );
   2825 
   2826         assert_eq!(
   2827             store.apply(AppStateCommand::succeed_pack_day_print(request.clone())),
   2828             Ok(true)
   2829         );
   2830         assert_eq!(
   2831             store.pack_day_projection().print,
   2832             PackDayPrintProjection::succeeded(request.clone())
   2833         );
   2834 
   2835         assert_eq!(
   2836             store.apply(AppStateCommand::fail_pack_day_print(request.clone())),
   2837             Ok(true)
   2838         );
   2839         assert_eq!(
   2840             store.pack_day_projection().print,
   2841             PackDayPrintProjection::failed(request.clone())
   2842         );
   2843 
   2844         assert_eq!(
   2845             store.apply(AppStateCommand::fail_pack_day_print_with_kind(
   2846                 request.clone(),
   2847                 PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow,
   2848             )),
   2849             Ok(true)
   2850         );
   2851         assert_eq!(
   2852             store.pack_day_projection().print,
   2853             PackDayPrintProjection::failed_with_kind(
   2854                 request,
   2855                 PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow,
   2856             )
   2857         );
   2858 
   2859         assert_eq!(
   2860             store.apply(AppStateCommand::reset_pack_day_print()),
   2861             Ok(true)
   2862         );
   2863         assert_eq!(
   2864             store.pack_day_projection().print,
   2865             PackDayPrintProjection::default()
   2866         );
   2867     }
   2868 
   2869     #[test]
   2870     fn pack_day_batch_print_state_is_restart_ephemeral_and_skips_persistence() {
   2871         let mut store =
   2872             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   2873         let fulfillment_window_id = FulfillmentWindowId::new();
   2874         let request = sample_pack_day_batch_print_request(fulfillment_window_id);
   2875 
   2876         assert_eq!(
   2877             request.artifacts,
   2878             Vec::from(PackDayBatchPrintArtifact::all_v1())
   2879         );
   2880         assert_eq!(
   2881             request
   2882                 .artifacts
   2883                 .last()
   2884                 .expect("v1 batch should include customer labels")
   2885                 .label_stock,
   2886             Some(PackDayPrintLabelStock::Avery5160Letter30Up)
   2887         );
   2888         assert_eq!(
   2889             store.apply(AppStateCommand::begin_pack_day_batch_print(request.clone(),)),
   2890             Ok(true)
   2891         );
   2892         assert_eq!(
   2893             store.pack_day_projection().batch_print,
   2894             PackDayBatchPrintProjection::running(request.clone())
   2895         );
   2896         assert_eq!(
   2897             store.persisted_state().seller.pack_day_query,
   2898             PackDayScreenQueryState::default()
   2899         );
   2900 
   2901         assert_eq!(
   2902             store.apply(AppStateCommand::succeed_pack_day_batch_print(
   2903                 request.clone(),
   2904             )),
   2905             Ok(true)
   2906         );
   2907         assert_eq!(
   2908             store.pack_day_projection().batch_print,
   2909             PackDayBatchPrintProjection::succeeded(request.clone())
   2910         );
   2911 
   2912         let failed_artifact =
   2913             PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintCustomerLabels);
   2914         assert_eq!(
   2915             store.apply(AppStateCommand::fail_pack_day_batch_print(
   2916                 request.clone(),
   2917                 Some(failed_artifact),
   2918                 PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow,
   2919             )),
   2920             Ok(true)
   2921         );
   2922         assert_eq!(
   2923             store.pack_day_projection().batch_print,
   2924             PackDayBatchPrintProjection::failed(
   2925                 request.clone(),
   2926                 Some(failed_artifact),
   2927                 PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow,
   2928             )
   2929         );
   2930 
   2931         assert_eq!(
   2932             store.apply(AppStateCommand::fail_pack_day_batch_print(
   2933                 request.clone(),
   2934                 None,
   2935                 PackDayBatchPrintFailureKind::Preflight,
   2936             )),
   2937             Ok(true)
   2938         );
   2939         assert_eq!(
   2940             store.pack_day_projection().batch_print,
   2941             PackDayBatchPrintProjection::failed(
   2942                 request,
   2943                 None,
   2944                 PackDayBatchPrintFailureKind::Preflight,
   2945             )
   2946         );
   2947 
   2948         assert_eq!(
   2949             store.apply(AppStateCommand::reset_pack_day_batch_print()),
   2950             Ok(true)
   2951         );
   2952         assert_eq!(
   2953             store.pack_day_projection().batch_print,
   2954             PackDayBatchPrintProjection::default()
   2955         );
   2956     }
   2957 
   2958     #[test]
   2959     fn pack_day_host_handoff_state_is_restart_ephemeral_and_skips_persistence() {
   2960         let mut store =
   2961             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   2962         let fulfillment_window_id = FulfillmentWindowId::new();
   2963         let request = sample_pack_day_host_handoff_request(
   2964             fulfillment_window_id,
   2965             PackDayHostHandoffKind::RevealBundle,
   2966         );
   2967 
   2968         assert_eq!(
   2969             store.apply(AppStateCommand::begin_pack_day_host_handoff(
   2970                 request.clone(),
   2971             )),
   2972             Ok(true)
   2973         );
   2974         assert_eq!(
   2975             store.pack_day_projection().host_handoff,
   2976             PackDayHostHandoffProjection::running(request.clone())
   2977         );
   2978         assert_eq!(
   2979             store.persisted_state().seller.pack_day_query,
   2980             PackDayScreenQueryState::default()
   2981         );
   2982 
   2983         assert_eq!(
   2984             store.apply(AppStateCommand::succeed_pack_day_host_handoff(
   2985                 request.clone(),
   2986             )),
   2987             Ok(true)
   2988         );
   2989         assert_eq!(
   2990             store.pack_day_projection().host_handoff,
   2991             PackDayHostHandoffProjection::succeeded(request.clone())
   2992         );
   2993 
   2994         assert_eq!(
   2995             store.apply(AppStateCommand::fail_pack_day_host_handoff(
   2996                 request.clone(),
   2997                 "finder unavailable",
   2998             )),
   2999             Ok(true)
   3000         );
   3001         assert_eq!(
   3002             store.pack_day_projection().host_handoff,
   3003             PackDayHostHandoffProjection::failed(request, "finder unavailable")
   3004         );
   3005 
   3006         assert_eq!(
   3007             store.apply(AppStateCommand::reset_pack_day_host_handoff()),
   3008             Ok(true)
   3009         );
   3010         assert_eq!(
   3011             store.pack_day_projection().host_handoff,
   3012             PackDayHostHandoffProjection::default()
   3013         );
   3014     }
   3015 
   3016     #[test]
   3017     fn changing_pack_day_window_clears_stale_export_state() {
   3018         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3019             .expect("in-memory repository should load");
   3020         let fulfillment_window_id = FulfillmentWindowId::new();
   3021         let next_window_id = FulfillmentWindowId::new();
   3022         let request = sample_pack_day_export_request(fulfillment_window_id);
   3023 
   3024         assert_eq!(
   3025             store.apply(AppStateCommand::begin_pack_day_export(request)),
   3026             Ok(true)
   3027         );
   3028         assert_eq!(
   3029             store.pack_day_projection().export.status,
   3030             PackDayExportStatus::Running
   3031         );
   3032 
   3033         assert_eq!(
   3034             store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some(
   3035                 next_window_id,
   3036             ))),
   3037             Ok(true)
   3038         );
   3039         assert_eq!(
   3040             store.pack_day_projection().query,
   3041             PackDayScreenQueryState {
   3042                 fulfillment_window_id: Some(next_window_id),
   3043             }
   3044         );
   3045         assert_eq!(
   3046             store.pack_day_projection().export,
   3047             PackDayExportProjection::default()
   3048         );
   3049     }
   3050 
   3051     #[test]
   3052     fn changing_pack_day_window_clears_stale_host_handoff_state() {
   3053         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3054             .expect("in-memory repository should load");
   3055         let fulfillment_window_id = FulfillmentWindowId::new();
   3056         let next_window_id = FulfillmentWindowId::new();
   3057         let request = sample_pack_day_host_handoff_request(
   3058             fulfillment_window_id,
   3059             PackDayHostHandoffKind::OpenPickupRoster,
   3060         );
   3061 
   3062         assert_eq!(
   3063             store.apply(AppStateCommand::begin_pack_day_host_handoff(request)),
   3064             Ok(true)
   3065         );
   3066         assert_eq!(
   3067             store.pack_day_projection().host_handoff.status,
   3068             PackDayHostHandoffStatus::Running
   3069         );
   3070 
   3071         assert_eq!(
   3072             store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some(
   3073                 next_window_id,
   3074             ))),
   3075             Ok(true)
   3076         );
   3077         assert_eq!(
   3078             store.pack_day_projection().query,
   3079             PackDayScreenQueryState {
   3080                 fulfillment_window_id: Some(next_window_id),
   3081             }
   3082         );
   3083         assert_eq!(
   3084             store.pack_day_projection().host_handoff,
   3085             PackDayHostHandoffProjection::default()
   3086         );
   3087     }
   3088 
   3089     #[test]
   3090     fn changing_pack_day_window_clears_stale_print_state() {
   3091         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3092             .expect("in-memory repository should load");
   3093         let fulfillment_window_id = FulfillmentWindowId::new();
   3094         let next_window_id = FulfillmentWindowId::new();
   3095         let request =
   3096             sample_pack_day_print_request(fulfillment_window_id, PackDayPrintKind::PrintPackSheet);
   3097 
   3098         assert_eq!(
   3099             store.apply(AppStateCommand::begin_pack_day_print(request)),
   3100             Ok(true)
   3101         );
   3102         assert_eq!(
   3103             store.pack_day_projection().print.status,
   3104             PackDayPrintStatus::Running
   3105         );
   3106 
   3107         assert_eq!(
   3108             store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some(
   3109                 next_window_id,
   3110             ))),
   3111             Ok(true)
   3112         );
   3113         assert_eq!(
   3114             store.pack_day_projection().query,
   3115             PackDayScreenQueryState {
   3116                 fulfillment_window_id: Some(next_window_id),
   3117             }
   3118         );
   3119         assert_eq!(
   3120             store.pack_day_projection().print,
   3121             PackDayPrintProjection::default()
   3122         );
   3123     }
   3124 
   3125     #[test]
   3126     fn changing_pack_day_window_clears_stale_batch_print_state() {
   3127         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3128             .expect("in-memory repository should load");
   3129         let fulfillment_window_id = FulfillmentWindowId::new();
   3130         let next_window_id = FulfillmentWindowId::new();
   3131         let request = sample_pack_day_batch_print_request(fulfillment_window_id);
   3132 
   3133         assert_eq!(
   3134             store.apply(AppStateCommand::begin_pack_day_batch_print(request)),
   3135             Ok(true)
   3136         );
   3137         assert_eq!(
   3138             store.pack_day_projection().batch_print.status,
   3139             PackDayBatchPrintStatus::Running
   3140         );
   3141 
   3142         assert_eq!(
   3143             store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some(
   3144                 next_window_id,
   3145             ))),
   3146             Ok(true)
   3147         );
   3148         assert_eq!(
   3149             store.pack_day_projection().query,
   3150             PackDayScreenQueryState {
   3151                 fulfillment_window_id: Some(next_window_id),
   3152             }
   3153         );
   3154         assert_eq!(
   3155             store.pack_day_projection().batch_print,
   3156             PackDayBatchPrintProjection::default()
   3157         );
   3158     }
   3159 
   3160     #[test]
   3161     fn changing_pack_day_export_state_clears_stale_host_handoff_state() {
   3162         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3163             .expect("in-memory repository should load");
   3164         let fulfillment_window_id = FulfillmentWindowId::new();
   3165         let export_request = sample_pack_day_export_request(fulfillment_window_id);
   3166         let host_handoff_request = sample_pack_day_host_handoff_request(
   3167             fulfillment_window_id,
   3168             PackDayHostHandoffKind::RevealBundle,
   3169         );
   3170 
   3171         assert_eq!(
   3172             store.apply(AppStateCommand::begin_pack_day_export(
   3173                 export_request.clone(),
   3174             )),
   3175             Ok(true)
   3176         );
   3177         assert_eq!(
   3178             store.apply(AppStateCommand::begin_pack_day_host_handoff(
   3179                 host_handoff_request,
   3180             )),
   3181             Ok(true)
   3182         );
   3183         assert_eq!(
   3184             store.pack_day_projection().host_handoff.status,
   3185             PackDayHostHandoffStatus::Running
   3186         );
   3187 
   3188         assert_eq!(
   3189             store.apply(AppStateCommand::succeed_pack_day_export(
   3190                 export_request,
   3191                 sample_pack_day_export_bundle(fulfillment_window_id),
   3192             )),
   3193             Ok(true)
   3194         );
   3195         assert_eq!(
   3196             store.pack_day_projection().host_handoff,
   3197             PackDayHostHandoffProjection::default()
   3198         );
   3199     }
   3200 
   3201     #[test]
   3202     fn changing_pack_day_export_state_clears_stale_print_state() {
   3203         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3204             .expect("in-memory repository should load");
   3205         let fulfillment_window_id = FulfillmentWindowId::new();
   3206         let export_request = sample_pack_day_export_request(fulfillment_window_id);
   3207         let print_request = sample_pack_day_print_request(
   3208             fulfillment_window_id,
   3209             PackDayPrintKind::PrintPickupRoster,
   3210         );
   3211 
   3212         assert_eq!(
   3213             store.apply(AppStateCommand::begin_pack_day_export(
   3214                 export_request.clone(),
   3215             )),
   3216             Ok(true)
   3217         );
   3218         assert_eq!(
   3219             store.apply(AppStateCommand::begin_pack_day_print(print_request)),
   3220             Ok(true)
   3221         );
   3222         assert_eq!(
   3223             store.pack_day_projection().print.status,
   3224             PackDayPrintStatus::Running
   3225         );
   3226 
   3227         assert_eq!(
   3228             store.apply(AppStateCommand::succeed_pack_day_export(
   3229                 export_request,
   3230                 sample_pack_day_export_bundle(fulfillment_window_id),
   3231             )),
   3232             Ok(true)
   3233         );
   3234         assert_eq!(
   3235             store.pack_day_projection().print,
   3236             PackDayPrintProjection::default()
   3237         );
   3238     }
   3239 
   3240     #[test]
   3241     fn changing_pack_day_export_state_clears_stale_batch_print_state() {
   3242         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3243             .expect("in-memory repository should load");
   3244         let fulfillment_window_id = FulfillmentWindowId::new();
   3245         let export_request = sample_pack_day_export_request(fulfillment_window_id);
   3246         let batch_request = sample_pack_day_batch_print_request(fulfillment_window_id);
   3247 
   3248         assert_eq!(
   3249             store.apply(AppStateCommand::begin_pack_day_export(
   3250                 export_request.clone(),
   3251             )),
   3252             Ok(true)
   3253         );
   3254         assert_eq!(
   3255             store.apply(AppStateCommand::begin_pack_day_batch_print(batch_request)),
   3256             Ok(true)
   3257         );
   3258         assert_eq!(
   3259             store.pack_day_projection().batch_print.status,
   3260             PackDayBatchPrintStatus::Running
   3261         );
   3262 
   3263         assert_eq!(
   3264             store.apply(AppStateCommand::succeed_pack_day_export(
   3265                 export_request,
   3266                 sample_pack_day_export_bundle(fulfillment_window_id),
   3267             )),
   3268             Ok(true)
   3269         );
   3270         assert_eq!(
   3271             store.pack_day_projection().batch_print,
   3272             PackDayBatchPrintProjection::default()
   3273         );
   3274     }
   3275 
   3276     #[test]
   3277     fn replacing_pack_day_projection_with_new_window_clears_stale_host_handoff_state() {
   3278         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3279             .expect("in-memory repository should load");
   3280         let farm_id = FarmId::new();
   3281         let current_window_id = FulfillmentWindowId::new();
   3282         let next_window_id = FulfillmentWindowId::new();
   3283         let request = sample_pack_day_host_handoff_request(
   3284             current_window_id,
   3285             PackDayHostHandoffKind::OpenCustomerLabels,
   3286         );
   3287 
   3288         assert_eq!(
   3289             store.apply(AppStateCommand::begin_pack_day_host_handoff(request)),
   3290             Ok(true)
   3291         );
   3292 
   3293         let next_pack_day = PackDayProjection {
   3294             fulfillment_window: Some(radroots_app_view::FulfillmentWindowSummary {
   3295                 fulfillment_window_id: next_window_id,
   3296                 farm_id,
   3297                 starts_at: "2026-04-25T16:00:00Z".to_owned(),
   3298                 ends_at: "2026-04-25T19:00:00Z".to_owned(),
   3299             }),
   3300             totals_by_product: Vec::new(),
   3301             pack_list: Vec::new(),
   3302             pickup_roster: Vec::new(),
   3303             reminders: ReminderFeedProjection::default(),
   3304         };
   3305 
   3306         assert_eq!(
   3307             store.apply(AppStateCommand::replace_pack_day_projection(
   3308                 next_pack_day.clone(),
   3309             )),
   3310             Ok(true)
   3311         );
   3312         assert_eq!(store.pack_day_projection().projection, next_pack_day);
   3313         assert_eq!(
   3314             store.pack_day_projection().host_handoff,
   3315             PackDayHostHandoffProjection::default()
   3316         );
   3317     }
   3318 
   3319     #[test]
   3320     fn replacing_pack_day_projection_with_new_window_clears_stale_print_state() {
   3321         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3322             .expect("in-memory repository should load");
   3323         let farm_id = FarmId::new();
   3324         let current_window_id = FulfillmentWindowId::new();
   3325         let next_window_id = FulfillmentWindowId::new();
   3326         let request =
   3327             sample_pack_day_print_request(current_window_id, PackDayPrintKind::PrintCustomerLabels);
   3328 
   3329         assert_eq!(
   3330             store.apply(AppStateCommand::begin_pack_day_print(request)),
   3331             Ok(true)
   3332         );
   3333 
   3334         let next_pack_day = PackDayProjection {
   3335             fulfillment_window: Some(radroots_app_view::FulfillmentWindowSummary {
   3336                 fulfillment_window_id: next_window_id,
   3337                 farm_id,
   3338                 starts_at: "2026-04-25T16:00:00Z".to_owned(),
   3339                 ends_at: "2026-04-25T19:00:00Z".to_owned(),
   3340             }),
   3341             totals_by_product: Vec::new(),
   3342             pack_list: Vec::new(),
   3343             pickup_roster: Vec::new(),
   3344             reminders: ReminderFeedProjection::default(),
   3345         };
   3346 
   3347         assert_eq!(
   3348             store.apply(AppStateCommand::replace_pack_day_projection(
   3349                 next_pack_day.clone(),
   3350             )),
   3351             Ok(true)
   3352         );
   3353         assert_eq!(store.pack_day_projection().projection, next_pack_day);
   3354         assert_eq!(
   3355             store.pack_day_projection().print,
   3356             PackDayPrintProjection::default()
   3357         );
   3358     }
   3359 
   3360     #[test]
   3361     fn replacing_pack_day_projection_with_new_window_clears_stale_batch_print_state() {
   3362         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3363             .expect("in-memory repository should load");
   3364         let farm_id = FarmId::new();
   3365         let current_window_id = FulfillmentWindowId::new();
   3366         let next_window_id = FulfillmentWindowId::new();
   3367         let request = sample_pack_day_batch_print_request(current_window_id);
   3368 
   3369         assert_eq!(
   3370             store.apply(AppStateCommand::begin_pack_day_batch_print(request)),
   3371             Ok(true)
   3372         );
   3373 
   3374         let next_pack_day = PackDayProjection {
   3375             fulfillment_window: Some(radroots_app_view::FulfillmentWindowSummary {
   3376                 fulfillment_window_id: next_window_id,
   3377                 farm_id,
   3378                 starts_at: "2026-04-25T16:00:00Z".to_owned(),
   3379                 ends_at: "2026-04-25T19:00:00Z".to_owned(),
   3380             }),
   3381             totals_by_product: Vec::new(),
   3382             pack_list: Vec::new(),
   3383             pickup_roster: Vec::new(),
   3384             reminders: ReminderFeedProjection::default(),
   3385         };
   3386 
   3387         assert_eq!(
   3388             store.apply(AppStateCommand::replace_pack_day_projection(
   3389                 next_pack_day.clone(),
   3390             )),
   3391             Ok(true)
   3392         );
   3393         assert_eq!(store.pack_day_projection().projection, next_pack_day);
   3394         assert_eq!(
   3395             store.pack_day_projection().batch_print,
   3396             PackDayBatchPrintProjection::default()
   3397         );
   3398     }
   3399 
   3400     #[test]
   3401     fn startup_identity_choice_flow_is_explicit_and_in_memory_only() {
   3402         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3403             .expect("in-memory repository should load");
   3404 
   3405         assert_eq!(
   3406             store.logged_out_startup_projection(),
   3407             &LoggedOutStartupProjection::default()
   3408         );
   3409 
   3410         assert_eq!(
   3411             store.apply(AppStateCommand::show_startup_identity_choice()),
   3412             Ok(true)
   3413         );
   3414         assert_eq!(
   3415             store.logged_out_startup_projection().phase,
   3416             LoggedOutStartupPhase::IdentityChoice
   3417         );
   3418 
   3419         assert_eq!(
   3420             store.apply(AppStateCommand::show_startup_signer_entry()),
   3421             Ok(true)
   3422         );
   3423         assert_eq!(
   3424             store.logged_out_startup_projection().phase,
   3425             LoggedOutStartupPhase::SignerEntry
   3426         );
   3427 
   3428         assert_eq!(
   3429             store.apply(AppStateCommand::set_startup_signer_source_input(
   3430                 "https://signer.radroots.example/connect?uri=bunker://npub1signer",
   3431             )),
   3432             Ok(true)
   3433         );
   3434         assert_eq!(
   3435             store
   3436                 .logged_out_startup_projection()
   3437                 .signer_entry
   3438                 .source_input,
   3439             "https://signer.radroots.example/connect?uri=bunker://npub1signer"
   3440         );
   3441 
   3442         assert_eq!(
   3443             store.apply(AppStateCommand::begin_generate_key_startup()),
   3444             Ok(true)
   3445         );
   3446         assert_eq!(
   3447             store.logged_out_startup_projection().phase,
   3448             LoggedOutStartupPhase::GenerateKeyStarting
   3449         );
   3450         assert_eq!(
   3451             store.repository().projection(),
   3452             AppShellProjection::default()
   3453         );
   3454         assert_eq!(
   3455             store
   3456                 .repository()
   3457                 .persisted_state()
   3458                 .logged_out_startup
   3459                 .phase,
   3460             LoggedOutStartupPhase::GenerateKeyStarting
   3461         );
   3462 
   3463         assert_eq!(
   3464             store.apply(AppStateCommand::reset_logged_out_startup()),
   3465             Ok(true)
   3466         );
   3467         assert_eq!(
   3468             store.logged_out_startup_projection(),
   3469             &LoggedOutStartupProjection::default()
   3470         );
   3471     }
   3472 
   3473     #[test]
   3474     fn product_editor_state_transitions_are_explicit() {
   3475         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3476             .expect("in-memory repository should load");
   3477         let product_id = ProductId::new();
   3478         let ready_draft = ProductEditorDraft {
   3479             title: "Heirloom tomatoes".to_owned(),
   3480             subtitle: "Brandywine".to_owned(),
   3481             category: "vegetables".to_owned(),
   3482             unit_label: "lb".to_owned(),
   3483             price_minor_units: Some(450),
   3484             price_currency: "USD".to_owned(),
   3485             stock_quantity: Some(12),
   3486             availability_window_id: Some(FulfillmentWindowId::new()),
   3487             status: radroots_app_view::ProductStatus::Draft,
   3488         };
   3489 
   3490         assert_eq!(
   3491             store.apply(AppStateCommand::open_new_product_editor()),
   3492             Ok(true)
   3493         );
   3494         assert_eq!(
   3495             store.projection().products.editor,
   3496             ProductEditorState::Open(super::ProductEditorSession {
   3497                 selected_product_id: None,
   3498                 draft: ProductEditorDraft::default(),
   3499                 publish_blockers: vec![
   3500                     ProductPublishBlocker::AddProductName,
   3501                     ProductPublishBlocker::ChooseCategory,
   3502                     ProductPublishBlocker::ChooseUnit,
   3503                     ProductPublishBlocker::SetPrice,
   3504                     ProductPublishBlocker::SetStock,
   3505                     ProductPublishBlocker::AttachAvailability,
   3506                 ],
   3507             })
   3508         );
   3509 
   3510         assert_eq!(
   3511             store.apply(AppStateCommand::replace_product_editor_draft(
   3512                 ready_draft.clone(),
   3513             )),
   3514             Ok(true)
   3515         );
   3516         assert_eq!(
   3517             store.projection().products.editor,
   3518             ProductEditorState::Open(super::ProductEditorSession {
   3519                 selected_product_id: None,
   3520                 draft: ready_draft.clone(),
   3521                 publish_blockers: vec![ProductPublishBlocker::AttachAvailability],
   3522             })
   3523         );
   3524 
   3525         assert_eq!(
   3526             store.apply(AppStateCommand::open_existing_product_editor(
   3527                 product_id,
   3528                 ready_draft.clone(),
   3529             )),
   3530             Ok(true)
   3531         );
   3532         assert_eq!(
   3533             store.projection().products.editor,
   3534             ProductEditorState::Open(super::ProductEditorSession {
   3535                 selected_product_id: Some(product_id),
   3536                 draft: ready_draft,
   3537                 publish_blockers: vec![ProductPublishBlocker::AttachAvailability],
   3538             })
   3539         );
   3540 
   3541         assert_eq!(
   3542             store.apply(AppStateCommand::close_product_editor()),
   3543             Ok(true)
   3544         );
   3545         assert_eq!(
   3546             store.projection().products.editor,
   3547             ProductEditorState::Closed
   3548         );
   3549         assert_eq!(
   3550             store.apply(AppStateCommand::replace_product_editor_draft(
   3551                 ProductEditorDraft::default(),
   3552             )),
   3553             Ok(false)
   3554         );
   3555     }
   3556 
   3557     #[test]
   3558     fn product_editor_publish_blockers_require_current_fulfillment_window() {
   3559         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3560             .expect("in-memory repository should load");
   3561         let farm_id = FarmId::new();
   3562         let pickup_location_id = PickupLocationId::new();
   3563         let active_window_id = FulfillmentWindowId::new();
   3564         let stale_window_id = FulfillmentWindowId::new();
   3565         let product_id = ProductId::new();
   3566         let publishable_draft = ProductEditorDraft {
   3567             title: "Salad mix".to_owned(),
   3568             subtitle: "Spring blend".to_owned(),
   3569             category: "greens".to_owned(),
   3570             unit_label: "bag".to_owned(),
   3571             price_minor_units: Some(900),
   3572             price_currency: "USD".to_owned(),
   3573             stock_quantity: Some(12),
   3574             availability_window_id: Some(active_window_id),
   3575             status: radroots_app_view::ProductStatus::Published,
   3576         };
   3577         let stale_draft = ProductEditorDraft {
   3578             availability_window_id: Some(stale_window_id),
   3579             ..publishable_draft.clone()
   3580         };
   3581 
   3582         assert_eq!(
   3583             store.apply(AppStateCommand::replace_farm_setup_projection(
   3584                 FarmSetupProjection::from_saved_farm(FarmSummary {
   3585                     farm_id,
   3586                     display_name: "North field farm".to_owned(),
   3587                     readiness: FarmReadiness::Ready,
   3588                 }),
   3589             )),
   3590             Ok(true)
   3591         );
   3592         assert_eq!(
   3593             store.apply(AppStateCommand::replace_farm_rules_projection(
   3594                 FarmRulesProjection {
   3595                     farm_profile: Some(FarmProfileRecord {
   3596                         farm_id,
   3597                         display_name: "North field farm".to_owned(),
   3598                         timezone: "UTC".to_owned(),
   3599                         currency_code: "USD".to_owned(),
   3600                     }),
   3601                     pickup_locations: vec![PickupLocationRecord {
   3602                         pickup_location_id,
   3603                         farm_id,
   3604                         label: "Barn pickup".to_owned(),
   3605                         address_line: "14 Orchard Lane".to_owned(),
   3606                         directions: None,
   3607                         is_default: true,
   3608                     }],
   3609                     operating_rules: Some(FarmOperatingRulesRecord {
   3610                         farm_id,
   3611                         promise_lead_hours: 24,
   3612                         substitution_policy: "ask_customer".to_owned(),
   3613                     }),
   3614                     fulfillment_windows: vec![FulfillmentWindowRecord {
   3615                         fulfillment_window_id: active_window_id,
   3616                         farm_id,
   3617                         pickup_location_id,
   3618                         label: "Friday pickup".to_owned(),
   3619                         starts_at: "2099-04-25T14:00:00Z".to_owned(),
   3620                         ends_at: "2099-04-25T18:00:00Z".to_owned(),
   3621                         order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(),
   3622                     }],
   3623                     blackout_periods: Vec::new(),
   3624                     readiness: FarmRulesReadiness::ready(),
   3625                 },
   3626             )),
   3627             Ok(true)
   3628         );
   3629 
   3630         assert_eq!(
   3631             store.apply(AppStateCommand::open_existing_product_editor(
   3632                 product_id,
   3633                 publishable_draft,
   3634             )),
   3635             Ok(true)
   3636         );
   3637         assert_eq!(
   3638             store.projection().products.editor,
   3639             ProductEditorState::Open(super::ProductEditorSession {
   3640                 selected_product_id: Some(product_id),
   3641                 draft: ProductEditorDraft {
   3642                     title: "Salad mix".to_owned(),
   3643                     subtitle: "Spring blend".to_owned(),
   3644                     category: "greens".to_owned(),
   3645                     unit_label: "bag".to_owned(),
   3646                     price_minor_units: Some(900),
   3647                     price_currency: "USD".to_owned(),
   3648                     stock_quantity: Some(12),
   3649                     availability_window_id: Some(active_window_id),
   3650                     status: radroots_app_view::ProductStatus::Published,
   3651                 },
   3652                 publish_blockers: Vec::new(),
   3653             })
   3654         );
   3655 
   3656         assert_eq!(
   3657             store.apply(AppStateCommand::replace_product_editor_draft(
   3658                 stale_draft.clone(),
   3659             )),
   3660             Ok(true)
   3661         );
   3662         assert_eq!(
   3663             store.projection().products.editor,
   3664             ProductEditorState::Open(super::ProductEditorSession {
   3665                 selected_product_id: Some(product_id),
   3666                 draft: stale_draft,
   3667                 publish_blockers: vec![ProductPublishBlocker::AttachAvailability],
   3668             })
   3669         );
   3670     }
   3671 
   3672     #[test]
   3673     fn select_settings_section_updates_shared_settings_without_clobbering_home() {
   3674         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3675             .expect("in-memory repository should load");
   3676 
   3677         let changed = store.apply(AppStateCommand::select_settings_section(
   3678             SettingsSection::Settings,
   3679         ));
   3680 
   3681         assert_eq!(changed, Ok(true));
   3682         assert_eq!(
   3683             store.projection().shell.active_surface,
   3684             ActiveSurface::Personal
   3685         );
   3686         assert_eq!(
   3687             store.projection().shell.selected_section,
   3688             ShellSection::Home
   3689         );
   3690         assert_eq!(
   3691             store.projection().shell.settings.selected_section,
   3692             SettingsSection::Settings
   3693         );
   3694         assert_eq!(
   3695             store.repository().projection().selected_section,
   3696             ShellSection::Home
   3697         );
   3698         assert_eq!(
   3699             store.repository().projection().settings.selected_section,
   3700             SettingsSection::Settings
   3701         );
   3702     }
   3703 
   3704     #[test]
   3705     fn select_farmer_section_without_identity_gate_is_rejected() {
   3706         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3707             .expect("in-memory repository should load");
   3708 
   3709         let changed = store.apply(AppStateCommand::SelectSection(ShellSection::Farmer(
   3710             FarmerSection::Products,
   3711         )));
   3712 
   3713         assert_eq!(changed, Ok(false));
   3714         assert_eq!(
   3715             store.projection().shell.selected_section,
   3716             ShellSection::Home
   3717         );
   3718         assert_eq!(
   3719             store.projection().shell.active_surface,
   3720             ActiveSurface::Personal
   3721         );
   3722     }
   3723 
   3724     #[test]
   3725     fn replacing_identity_projection_with_farmer_activation_moves_home_to_farmer_today() {
   3726         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3727             .expect("in-memory repository should load");
   3728 
   3729         let changed = store.apply(AppStateCommand::replace_identity_projection(
   3730             ready_identity(ActiveSurface::Farmer),
   3731         ));
   3732 
   3733         assert_eq!(changed, Ok(true));
   3734         assert_eq!(store.startup_gate(), AppStartupGate::Farmer);
   3735         assert_eq!(
   3736             store.projection().shell.active_surface,
   3737             ActiveSurface::Farmer
   3738         );
   3739         assert_eq!(
   3740             store.projection().shell.selected_section,
   3741             ShellSection::Farmer(FarmerSection::Today)
   3742         );
   3743         assert_eq!(store.home_route(), HomeRoute::FarmSetupOnboarding);
   3744     }
   3745 
   3746     #[test]
   3747     fn replacing_identity_projection_makes_signed_in_personal_entry_explicit_without_rewriting_home()
   3748      {
   3749         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3750             .expect("in-memory repository should load");
   3751 
   3752         let changed = store.apply(AppStateCommand::replace_identity_projection(
   3753             ready_identity(ActiveSurface::Personal),
   3754         ));
   3755 
   3756         assert_eq!(changed, Ok(true));
   3757         assert_eq!(store.startup_gate(), AppStartupGate::Personal);
   3758         assert_eq!(
   3759             store.projection().shell.selected_section,
   3760             ShellSection::Home
   3761         );
   3762         assert_eq!(
   3763             store.personal_projection().entry.state,
   3764             PersonalEntryState::SignedIn
   3765         );
   3766     }
   3767 
   3768     #[test]
   3769     fn startup_identity_choice_state_resets_once_identity_leaves_setup_required() {
   3770         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3771             .expect("in-memory repository should load");
   3772 
   3773         assert_eq!(
   3774             store.apply(AppStateCommand::show_startup_identity_choice()),
   3775             Ok(true)
   3776         );
   3777         assert_eq!(
   3778             store.apply(AppStateCommand::show_startup_signer_entry()),
   3779             Ok(true)
   3780         );
   3781         assert_eq!(
   3782             store.apply(AppStateCommand::set_startup_signer_source_input(
   3783                 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example",
   3784             )),
   3785             Ok(true)
   3786         );
   3787 
   3788         assert_eq!(
   3789             store.apply(AppStateCommand::replace_identity_projection(
   3790                 ready_identity(ActiveSurface::Personal),
   3791             )),
   3792             Ok(true)
   3793         );
   3794         assert_eq!(store.startup_gate(), AppStartupGate::Personal);
   3795         assert_eq!(
   3796             store.logged_out_startup_projection(),
   3797             &LoggedOutStartupProjection::default()
   3798         );
   3799     }
   3800 
   3801     #[test]
   3802     fn startup_identity_choice_commands_are_rejected_after_setup_gate_is_cleared() {
   3803         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3804             .expect("in-memory repository should load");
   3805 
   3806         assert_eq!(
   3807             store.apply(AppStateCommand::replace_identity_projection(
   3808                 ready_identity(ActiveSurface::Personal),
   3809             )),
   3810             Ok(true)
   3811         );
   3812 
   3813         assert_eq!(
   3814             store.apply(AppStateCommand::show_startup_identity_choice()),
   3815             Ok(false)
   3816         );
   3817         assert_eq!(
   3818             store.apply(AppStateCommand::show_startup_signer_entry()),
   3819             Ok(false)
   3820         );
   3821         assert_eq!(
   3822             store.apply(AppStateCommand::begin_generate_key_startup()),
   3823             Ok(false)
   3824         );
   3825         assert_eq!(
   3826             store.apply(AppStateCommand::set_startup_signer_source_input(
   3827                 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example",
   3828             )),
   3829             Ok(false)
   3830         );
   3831         assert_eq!(
   3832             store.logged_out_startup_projection(),
   3833             &LoggedOutStartupProjection::default()
   3834         );
   3835     }
   3836 
   3837     #[test]
   3838     fn select_active_surface_moves_personal_home_to_farmer_today() {
   3839         let repository = InMemoryAppStateRepository::new(AppShellProjection::for_surface(
   3840             ActiveSurface::Personal,
   3841         ));
   3842         let mut store = AppStateStore::load(repository).expect("in-memory repository should load");
   3843         assert_eq!(
   3844             store.apply(AppStateCommand::replace_identity_projection(
   3845                 ready_identity(ActiveSurface::Personal,)
   3846             )),
   3847             Ok(true)
   3848         );
   3849 
   3850         let changed = store.apply(AppStateCommand::select_active_surface(
   3851             ActiveSurface::Farmer,
   3852         ));
   3853 
   3854         assert_eq!(changed, Ok(true));
   3855         assert_eq!(
   3856             store.projection().shell.active_surface,
   3857             ActiveSurface::Farmer
   3858         );
   3859         assert_eq!(
   3860             store.projection().shell.selected_section,
   3861             ShellSection::Farmer(FarmerSection::Today)
   3862         );
   3863         assert_eq!(
   3864             store
   3865                 .identity_projection()
   3866                 .selected_account
   3867                 .as_ref()
   3868                 .expect("selected account")
   3869                 .active_surface(),
   3870             ActiveSurface::Farmer
   3871         );
   3872     }
   3873 
   3874     #[test]
   3875     fn select_active_surface_moves_farmer_routes_back_to_home_for_personal() {
   3876         let repository = InMemoryAppStateRepository::new(AppShellProjection::new(
   3877             ActiveSurface::Farmer,
   3878             ShellSection::Farmer(FarmerSection::Products),
   3879         ));
   3880         let mut store = AppStateStore::load(repository).expect("in-memory repository should load");
   3881         assert_eq!(
   3882             store.apply(AppStateCommand::replace_identity_projection(
   3883                 ready_identity(ActiveSurface::Farmer,)
   3884             )),
   3885             Ok(true)
   3886         );
   3887 
   3888         let changed = store.apply(AppStateCommand::select_active_surface(
   3889             ActiveSurface::Personal,
   3890         ));
   3891 
   3892         assert_eq!(changed, Ok(true));
   3893         assert_eq!(
   3894             store.projection().shell.active_surface,
   3895             ActiveSurface::Personal
   3896         );
   3897         assert_eq!(
   3898             store.projection().shell.selected_section,
   3899             ShellSection::Personal(PersonalSection::Browse)
   3900         );
   3901         assert_eq!(store.startup_gate(), AppStartupGate::Personal);
   3902     }
   3903 
   3904     #[test]
   3905     fn select_active_surface_moves_account_route_to_personal_default() {
   3906         let repository = InMemoryAppStateRepository::new(AppShellProjection::new(
   3907             ActiveSurface::Personal,
   3908             ShellSection::Account,
   3909         ));
   3910         let mut store = AppStateStore::load(repository).expect("in-memory repository should load");
   3911         assert_eq!(
   3912             store.apply(AppStateCommand::replace_identity_projection(
   3913                 ready_identity(ActiveSurface::Personal,)
   3914             )),
   3915             Ok(true)
   3916         );
   3917 
   3918         let changed = store.apply(AppStateCommand::select_active_surface(
   3919             ActiveSurface::Personal,
   3920         ));
   3921 
   3922         assert_eq!(changed, Ok(true));
   3923         assert_eq!(
   3924             store.projection().shell.active_surface,
   3925             ActiveSurface::Personal
   3926         );
   3927         assert_eq!(
   3928             store.projection().shell.selected_section,
   3929             ShellSection::Personal(PersonalSection::Browse)
   3930         );
   3931     }
   3932 
   3933     #[test]
   3934     fn select_active_surface_moves_account_route_to_farmer_default() {
   3935         let repository = InMemoryAppStateRepository::new(AppShellProjection::new(
   3936             ActiveSurface::Farmer,
   3937             ShellSection::Account,
   3938         ));
   3939         let mut store = AppStateStore::load(repository).expect("in-memory repository should load");
   3940         assert_eq!(
   3941             store.apply(AppStateCommand::replace_identity_projection(
   3942                 ready_identity(ActiveSurface::Farmer,)
   3943             )),
   3944             Ok(true)
   3945         );
   3946 
   3947         let changed = store.apply(AppStateCommand::select_active_surface(
   3948             ActiveSurface::Farmer,
   3949         ));
   3950 
   3951         assert_eq!(changed, Ok(true));
   3952         assert_eq!(
   3953             store.projection().shell.active_surface,
   3954             ActiveSurface::Farmer
   3955         );
   3956         assert_eq!(
   3957             store.projection().shell.selected_section,
   3958             ShellSection::Farmer(FarmerSection::Today)
   3959         );
   3960     }
   3961 
   3962     #[test]
   3963     fn select_active_surface_preserves_settings_route() {
   3964         let repository = InMemoryAppStateRepository::new(AppShellProjection::for_settings(
   3965             ActiveSurface::Personal,
   3966             SettingsSection::About,
   3967         ));
   3968         let mut store = AppStateStore::load(repository).expect("in-memory repository should load");
   3969         assert_eq!(
   3970             store.apply(AppStateCommand::replace_identity_projection(
   3971                 ready_identity(ActiveSurface::Personal,)
   3972             )),
   3973             Ok(true)
   3974         );
   3975 
   3976         let changed = store.apply(AppStateCommand::select_active_surface(
   3977             ActiveSurface::Farmer,
   3978         ));
   3979 
   3980         assert_eq!(changed, Ok(true));
   3981         assert_eq!(
   3982             store.projection().shell.active_surface,
   3983             ActiveSurface::Farmer
   3984         );
   3985         assert_eq!(
   3986             store.projection().shell.selected_section,
   3987             ShellSection::Settings(SettingsSection::About)
   3988         );
   3989     }
   3990 
   3991     #[test]
   3992     fn settings_preference_command_is_a_noop_when_value_is_unchanged() {
   3993         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   3994             .expect("in-memory repository should load");
   3995 
   3996         let changed = store.apply(AppStateCommand::SetSettingsPreference {
   3997             preference: SettingsPreference::UseNip05,
   3998             enabled: true,
   3999         });
   4000 
   4001         assert_eq!(changed, Ok(false));
   4002         assert!(store.projection().shell.settings.general.use_nip05);
   4003     }
   4004 
   4005     #[test]
   4006     fn settings_preference_command_updates_projection_and_repository() {
   4007         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   4008             .expect("in-memory repository should load");
   4009 
   4010         let changed = store.apply(AppStateCommand::SetSettingsPreference {
   4011             preference: SettingsPreference::LaunchAtLogin,
   4012             enabled: true,
   4013         });
   4014 
   4015         assert_eq!(changed, Ok(true));
   4016         assert!(store.projection().shell.settings.general.launch_at_login);
   4017         assert!(
   4018             !store
   4019                 .repository()
   4020                 .projection()
   4021                 .settings
   4022                 .general
   4023                 .launch_at_login
   4024         );
   4025     }
   4026 
   4027     #[test]
   4028     fn repository_errors_bubble_out_of_the_store() {
   4029         let mut store =
   4030             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   4031 
   4032         let error = store
   4033             .apply(AppStateCommand::select_settings_section(
   4034                 SettingsSection::About,
   4035             ))
   4036             .expect_err("save should fail");
   4037 
   4038         assert_eq!(
   4039             error,
   4040             AppStateStoreError::Repository(AppStateRepositoryError::save("disk unavailable"))
   4041         );
   4042     }
   4043 
   4044     #[test]
   4045     fn replace_today_agenda_updates_in_memory_state_without_touching_repository() {
   4046         let mut store =
   4047             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   4048         let farm_id = FarmId::new();
   4049         let today = TodayAgendaProjection {
   4050             farm: Some(radroots_app_view::FarmSummary {
   4051                 farm_id,
   4052                 display_name: "North field farm".to_owned(),
   4053                 readiness: FarmReadiness::Incomplete,
   4054             }),
   4055             setup_checklist: vec![TodaySetupTask {
   4056                 kind: TodaySetupTaskKind::AddFulfillmentWindow,
   4057                 is_complete: false,
   4058             }],
   4059             ..TodayAgendaProjection::default()
   4060         };
   4061 
   4062         let changed = store.apply(AppStateCommand::replace_today_agenda(today.clone()));
   4063 
   4064         assert_eq!(changed, Ok(true));
   4065         assert_eq!(store.projection().today.farm, today.farm);
   4066         assert_eq!(store.projection().today.setup_checklist.len(), 6);
   4067         assert!(store.projection().today.needs_setup());
   4068     }
   4069 
   4070     #[test]
   4071     fn replace_farm_setup_projection_updates_in_memory_state_without_touching_repository() {
   4072         let mut store =
   4073             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   4074         let farm_setup = FarmSetupProjection::from_draft(FarmSetupDraft::new(
   4075             "North field farm",
   4076             "",
   4077             [FarmOrderMethod::Pickup],
   4078         ));
   4079 
   4080         let changed = store.apply(AppStateCommand::replace_farm_setup_projection(
   4081             farm_setup.clone(),
   4082         ));
   4083 
   4084         assert_eq!(changed, Ok(true));
   4085         assert_eq!(store.farm_setup_projection(), &farm_setup);
   4086     }
   4087 
   4088     #[test]
   4089     fn select_farm_setup_flow_stage_switches_farmer_home_into_form_route() {
   4090         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   4091             .expect("in-memory repository should load");
   4092 
   4093         assert_eq!(
   4094             store.apply(AppStateCommand::replace_identity_projection(
   4095                 ready_identity(ActiveSurface::Farmer),
   4096             )),
   4097             Ok(true)
   4098         );
   4099         assert_eq!(store.home_route(), HomeRoute::FarmSetupOnboarding);
   4100 
   4101         let changed = store.apply(AppStateCommand::select_farm_setup_flow_stage(
   4102             FarmSetupFlowStage::Editing,
   4103         ));
   4104 
   4105         assert_eq!(changed, Ok(true));
   4106         assert_eq!(store.home_route(), HomeRoute::FarmSetupForm);
   4107     }
   4108 
   4109     #[test]
   4110     fn complete_draft_without_saved_farm_stays_on_farm_setup_form() {
   4111         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   4112             .expect("in-memory repository should load");
   4113 
   4114         assert_eq!(
   4115             store.apply(AppStateCommand::replace_identity_projection(
   4116                 ready_identity(ActiveSurface::Farmer),
   4117             )),
   4118             Ok(true)
   4119         );
   4120 
   4121         let changed = store.apply(AppStateCommand::replace_farm_setup_projection(
   4122             FarmSetupProjection::from_draft(FarmSetupDraft::new(
   4123                 "North field farm",
   4124                 "Stockholm County",
   4125                 [FarmOrderMethod::Pickup],
   4126             )),
   4127         ));
   4128 
   4129         assert_eq!(changed, Ok(true));
   4130         assert_eq!(store.home_route(), HomeRoute::FarmSetupForm);
   4131         assert_eq!(
   4132             store.projection().farm_setup_flow_stage,
   4133             FarmSetupFlowStage::Onboarding
   4134         );
   4135     }
   4136 
   4137     #[test]
   4138     fn saved_farm_in_today_projection_synchronizes_ready_home_route() {
   4139         let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
   4140             .expect("in-memory repository should load");
   4141         let farm_id = FarmId::new();
   4142 
   4143         assert_eq!(
   4144             store.apply(AppStateCommand::replace_identity_projection(
   4145                 ready_identity(ActiveSurface::Farmer),
   4146             )),
   4147             Ok(true)
   4148         );
   4149 
   4150         let changed = store.apply(AppStateCommand::replace_today_agenda(
   4151             TodayAgendaProjection {
   4152                 farm: Some(radroots_app_view::FarmSummary {
   4153                     farm_id,
   4154                     display_name: "North field farm".to_owned(),
   4155                     readiness: FarmReadiness::Ready,
   4156                 }),
   4157                 ..TodayAgendaProjection::default()
   4158             },
   4159         ));
   4160 
   4161         assert_eq!(changed, Ok(true));
   4162         assert_eq!(store.home_route(), HomeRoute::Today);
   4163         assert_eq!(
   4164             store
   4165                 .farm_setup_projection()
   4166                 .saved_farm
   4167                 .as_ref()
   4168                 .expect("saved farm")
   4169                 .farm_id,
   4170             farm_id
   4171         );
   4172     }
   4173 
   4174     #[test]
   4175     fn replacing_identity_projection_surfaces_settings_account_state_without_touching_repository() {
   4176         let mut store =
   4177             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   4178 
   4179         let changed = store.apply(AppStateCommand::replace_identity_projection(
   4180             ready_identity(ActiveSurface::Personal),
   4181         ));
   4182 
   4183         assert_eq!(changed, Ok(true));
   4184         assert_eq!(store.startup_gate(), AppStartupGate::Personal);
   4185         assert_eq!(store.settings_account_projection().roster.len(), 1);
   4186         assert_eq!(
   4187             store
   4188                 .settings_account_projection()
   4189                 .selected_account
   4190                 .as_ref()
   4191                 .expect("selected account")
   4192                 .account
   4193                 .account_id,
   4194             "acct_surface"
   4195         );
   4196     }
   4197 
   4198     #[test]
   4199     fn replace_sync_projection_updates_in_memory_state_without_touching_repository() {
   4200         let mut store =
   4201             AppStateStore::load(FailingRepository).expect("failing repository should still load");
   4202         let checkpoint = SyncCheckpointStatus::current(
   4203             None,
   4204             "2026-04-20T19:00:00Z",
   4205             Some("cursor-1".to_owned()),
   4206         );
   4207         let sync_projection = derive_sync_projection(&checkpoint, &[]);
   4208 
   4209         let changed = store.apply(AppStateCommand::replace_sync_projection(
   4210             sync_projection.clone(),
   4211         ));
   4212 
   4213         assert_eq!(changed, Ok(true));
   4214         assert_eq!(store.sync_projection(), &sync_projection);
   4215     }
   4216 
   4217     #[test]
   4218     fn derive_sync_run_status_prefers_syncing_failed_and_conflicted_states_explicitly() {
   4219         assert_eq!(
   4220             derive_sync_run_status(
   4221                 &SyncCheckpointStatus::syncing("2026-04-20T18:00:00Z", None),
   4222                 &SyncConflictStatus::clear(),
   4223             ),
   4224             AppSyncRunStatus::Syncing
   4225         );
   4226         assert_eq!(
   4227             derive_sync_run_status(
   4228                 &SyncCheckpointStatus::failed(None, None, None, "relay unavailable"),
   4229                 &SyncConflictStatus::clear(),
   4230             ),
   4231             AppSyncRunStatus::Failed
   4232         );
   4233         assert_eq!(
   4234             derive_sync_run_status(
   4235                 &SyncCheckpointStatus {
   4236                     state: SyncCheckpointState::Current,
   4237                     ..SyncCheckpointStatus::never_synced()
   4238                 },
   4239                 &SyncConflictStatus {
   4240                     unresolved_count: 1,
   4241                     blocking_count: 1,
   4242                 },
   4243             ),
   4244             AppSyncRunStatus::Conflicted
   4245         );
   4246         assert_eq!(
   4247             derive_sync_run_status(
   4248                 &SyncCheckpointStatus::current(None, "2026-04-20T19:00:00Z", None),
   4249                 &SyncConflictStatus::clear(),
   4250             ),
   4251             AppSyncRunStatus::Succeeded
   4252         );
   4253         assert_eq!(
   4254             derive_sync_run_status(
   4255                 &SyncCheckpointStatus::never_synced(),
   4256                 &SyncConflictStatus::clear(),
   4257             ),
   4258             AppSyncRunStatus::Idle
   4259         );
   4260     }
   4261 
   4262     #[test]
   4263     fn derive_sync_projection_counts_unresolved_conflicts_from_typed_rows() {
   4264         let checkpoint = SyncCheckpointStatus::current(
   4265             None,
   4266             "2026-04-20T19:00:00Z",
   4267             Some("cursor-2".to_owned()),
   4268         );
   4269         let conflicts = vec![
   4270             SyncConflict {
   4271                 aggregate: radroots_app_sync::SyncAggregateRef::Farm(FarmId::new()),
   4272                 kind: SyncConflictKind::RevisionMismatch,
   4273                 severity: SyncConflictSeverity::Blocking,
   4274                 resolution: SyncConflictResolutionStatus::Unresolved,
   4275                 local_payload_json: "{\"farm\":\"local\"}".to_owned(),
   4276                 remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()),
   4277                 detected_at: "2026-04-20T19:01:00Z".to_owned(),
   4278                 resolved_at: None,
   4279             },
   4280             SyncConflict {
   4281                 aggregate: radroots_app_sync::SyncAggregateRef::Farm(FarmId::new()),
   4282                 kind: SyncConflictKind::RemoteValidationReject,
   4283                 severity: SyncConflictSeverity::ReviewRequired,
   4284                 resolution: SyncConflictResolutionStatus::AcceptedRemote,
   4285                 local_payload_json: "{\"farm\":\"local-two\"}".to_owned(),
   4286                 remote_payload_json: None,
   4287                 detected_at: "2026-04-20T19:02:00Z".to_owned(),
   4288                 resolved_at: Some("2026-04-20T19:03:00Z".to_owned()),
   4289             },
   4290         ];
   4291 
   4292         let projection = derive_sync_projection(&checkpoint, &conflicts);
   4293 
   4294         assert_eq!(projection.run_status, AppSyncRunStatus::Conflicted);
   4295         assert_eq!(projection.checkpoint, checkpoint);
   4296         assert_eq!(projection.conflict_status.unresolved_count, 1);
   4297         assert_eq!(projection.conflict_status.blocking_count, 1);
   4298     }
   4299 
   4300     #[test]
   4301     fn in_memory_store_construction_and_updates_are_infallible() {
   4302         let mut store = AppStateStore::in_memory(AppShellProjection::for_settings(
   4303             ActiveSurface::Farmer,
   4304             SettingsSection::Account,
   4305         ));
   4306 
   4307         let changed = store.apply_in_memory(AppStateCommand::SetSettingsPreference {
   4308             preference: SettingsPreference::AllowRelayConnections,
   4309             enabled: false,
   4310         });
   4311 
   4312         assert!(changed);
   4313         assert!(
   4314             !store
   4315                 .projection()
   4316                 .shell
   4317                 .settings
   4318                 .general
   4319                 .allow_relay_connections
   4320         );
   4321         assert!(
   4322             store
   4323                 .repository()
   4324                 .projection()
   4325                 .settings
   4326                 .general
   4327                 .allow_relay_connections
   4328         );
   4329     }
   4330 
   4331     #[test]
   4332     fn app_projection_defaults_the_new_reminder_contracts() {
   4333         let projection = AppProjection::default();
   4334 
   4335         assert!(projection.today.reminders.is_empty());
   4336         assert!(projection.orders.reminders.is_empty());
   4337         assert!(projection.reminder_log.is_empty());
   4338         assert!(projection.pack_day.projection.reminders.is_empty());
   4339         assert_eq!(
   4340             projection.orders.reminders,
   4341             ReminderFeedProjection::default()
   4342         );
   4343     }
   4344 }