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 }