app

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

commit cd0a000a1ed470cc28fd22689a170ffebbc63d47
parent 2a3c0de2c0d1ae222d5e5457bef89b660145637b
Author: triesap <tyson@radroots.org>
Date:   Wed, 22 Apr 2026 20:22:02 +0000

pack_day: add print request contracts

- add export_instance_id to pack day export bundles and freeze v1 print kind plus stock enums
- add restart-ephemeral pack day print state that resets when export truth changes
- add english queued submitted failed and unavailable print contract strings
- prove the contract with pack day model state and UI-facing tests

Diffstat:
Mcrates/launchers/desktop/src/pack_day_host_handoff.rs | 1+
Mcrates/launchers/desktop/src/window.rs | 2++
Mcrates/shared/core/src/pack_day_export.rs | 4+++-
Mcrates/shared/i18n/src/keys.rs | 11+++++++++++
Mcrates/shared/i18n/src/lib.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Mcrates/shared/models/src/lib.rs | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/shared/state/src/lib.rs | 321++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mi18n/locales/en/messages.json | 11+++++++++++
8 files changed, 509 insertions(+), 20 deletions(-)

diff --git a/crates/launchers/desktop/src/pack_day_host_handoff.rs b/crates/launchers/desktop/src/pack_day_host_handoff.rs @@ -347,6 +347,7 @@ mod tests { fn sample_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { PackDayExportBundle { fulfillment_window_id: radroots_app_models::FulfillmentWindowId::new(), + export_instance_id: radroots_app_models::PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: bundle_directory.to_string_lossy().into_owned(), artifacts: vec![ diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -12343,6 +12343,7 @@ mod tests { fn sample_pack_day_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { PackDayExportBundle { fulfillment_window_id: FulfillmentWindowId::new(), + export_instance_id: radroots_app_models::PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: bundle_directory.to_string_lossy().into_owned(), artifacts: vec![ @@ -12991,6 +12992,7 @@ mod tests { let fulfillment_window_id = FulfillmentWindowId::new(); let bundle = PackDayExportBundle { fulfillment_window_id, + export_instance_id: radroots_app_models::PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), artifacts: vec![ diff --git a/crates/shared/core/src/pack_day_export.rs b/crates/shared/core/src/pack_day_export.rs @@ -5,7 +5,8 @@ use std::{ use chrono::{DateTime, Utc}; use radroots_app_models::{ - PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, PackDayOutputSource, + PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, + PackDayOutputSource, }; use thiserror::Error; @@ -86,6 +87,7 @@ pub fn prepare_pack_day_export_bundle_at_data_root( .collect::<Vec<_>>(); let bundle = PackDayExportBundle { fulfillment_window_id: source.fulfillment_window.fulfillment_window_id, + export_instance_id: PackDayExportInstanceId::new(), generated_at_utc: generated_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), bundle_directory: bundle_directory.to_string_lossy().into_owned(), artifacts, diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -241,6 +241,17 @@ define_app_text_keys! { PackDayExportFolderLabel => "pack_day.export.folder.label", PackDayExportFilesLabel => "pack_day.export.files.label", PackDayExportErrorLabel => "pack_day.export.error.label", + PackDayPrintUnavailableTitle => "pack_day.print.unavailable.title", + PackDayPrintUnavailableBody => "pack_day.print.unavailable.body", + PackDayPrintPackSheetQueuedTitle => "pack_day.print.pack_sheet.queued.title", + PackDayPrintPackSheetSubmittedTitle => "pack_day.print.pack_sheet.submitted.title", + PackDayPrintPackSheetFailedTitle => "pack_day.print.pack_sheet.failed.title", + PackDayPrintPickupRosterQueuedTitle => "pack_day.print.pickup_roster.queued.title", + PackDayPrintPickupRosterSubmittedTitle => "pack_day.print.pickup_roster.submitted.title", + PackDayPrintPickupRosterFailedTitle => "pack_day.print.pickup_roster.failed.title", + PackDayPrintCustomerLabelsQueuedTitle => "pack_day.print.customer_labels.queued.title", + PackDayPrintCustomerLabelsSubmittedTitle => "pack_day.print.customer_labels.submitted.title", + PackDayPrintCustomerLabelsFailedTitle => "pack_day.print.customer_labels.failed.title", PackDayHostHandoffRevealAction => "pack_day.host_handoff.reveal.action", PackDayHostHandoffRevealActionRunning => "pack_day.host_handoff.reveal.action.running", PackDayHostHandoffOpenPackSheetAction => "pack_day.host_handoff.open_pack_sheet.action", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -476,6 +476,50 @@ mod tests { assert_eq!(app_text(AppTextKey::PackDayExportFilesLabel), "Files"); assert_eq!(app_text(AppTextKey::PackDayExportErrorLabel), "Error"); assert_eq!( + app_text(AppTextKey::PackDayPrintUnavailableTitle), + "Print not available yet" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintUnavailableBody), + "Print actions become available after pack day files are saved locally." + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintPackSheetQueuedTitle), + "Queueing pack sheet" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintPackSheetSubmittedTitle), + "Sent pack sheet to the printer" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintPackSheetFailedTitle), + "Couldn't print pack sheet" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintPickupRosterQueuedTitle), + "Queueing pickup roster" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintPickupRosterSubmittedTitle), + "Sent pickup roster to the printer" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintPickupRosterFailedTitle), + "Couldn't print pickup roster" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintCustomerLabelsQueuedTitle), + "Queueing customer labels" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintCustomerLabelsSubmittedTitle), + "Sent customer labels to the printer" + ); + assert_eq!( + app_text(AppTextKey::PackDayPrintCustomerLabelsFailedTitle), + "Couldn't print customer labels" + ); + assert_eq!( app_text(AppTextKey::PackDayHostHandoffRevealAction), "Show in Finder" ); diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -238,6 +238,7 @@ typed_id!(BlackoutPeriodId); typed_id!(ProductId); typed_id!(OrderId); typed_id!(FulfillmentWindowId); +typed_id!(PackDayExportInstanceId); typed_id!(ActivityEventId); typed_id!(ReminderId); typed_id!(RecoveryRecordId); @@ -1611,6 +1612,86 @@ impl PackDayExportArtifactKind { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +pub enum PackDayPrintKind { + PrintPackSheet, + PrintPickupRoster, + PrintCustomerLabels, +} + +impl PackDayPrintKind { + pub const fn all_v1() -> [Self; 3] { + [ + Self::PrintPackSheet, + Self::PrintPickupRoster, + Self::PrintCustomerLabels, + ] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::PrintPackSheet => "print_pack_sheet", + Self::PrintPickupRoster => "print_pickup_roster", + Self::PrintCustomerLabels => "print_customer_labels", + } + } + + pub const fn artifact_kind(self) -> PackDayExportArtifactKind { + match self { + Self::PrintPackSheet => PackDayExportArtifactKind::PackSheet, + Self::PrintPickupRoster => PackDayExportArtifactKind::PickupRoster, + Self::PrintCustomerLabels => PackDayExportArtifactKind::CustomerLabels, + } + } + + pub const fn label_stock(self) -> Option<PackDayPrintLabelStock> { + match self { + Self::PrintPackSheet | Self::PrintPickupRoster => None, + Self::PrintCustomerLabels => Some(PackDayPrintLabelStock::Avery5160Letter30Up), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayPrintLabelStock { + Avery5160Letter30Up, +} + +impl PackDayPrintLabelStock { + pub const fn all_v1() -> [Self; 1] { + [Self::Avery5160Letter30Up] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::Avery5160Letter30Up => "avery_5160_letter_30_up", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayPrintStatus { + #[default] + Idle, + Running, + Succeeded, + Failed, +} + +impl PackDayPrintStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Running => "running", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum PackDayHostHandoffKind { RevealBundle, OpenPackSheet, @@ -1804,6 +1885,7 @@ pub struct PackDayExportArtifact { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct PackDayExportBundle { pub fulfillment_window_id: FulfillmentWindowId, + pub export_instance_id: PackDayExportInstanceId, pub generated_at_utc: String, pub bundle_directory: String, pub artifacts: Vec<PackDayExportArtifact>, @@ -3001,7 +3083,7 @@ mod tests { } #[test] - fn pack_day_export_and_host_handoff_contracts_are_frozen_for_v1() { + fn pack_day_export_print_and_host_handoff_contracts_are_frozen_for_v1() { assert_eq!( PackDayExportArtifactKind::all_v1(), [ @@ -3031,6 +3113,56 @@ mod tests { assert_eq!(PackDayExportStatus::Succeeded.storage_key(), "succeeded"); assert_eq!(PackDayExportStatus::Failed.storage_key(), "failed"); assert_eq!( + PackDayPrintKind::all_v1(), + [ + PackDayPrintKind::PrintPackSheet, + PackDayPrintKind::PrintPickupRoster, + PackDayPrintKind::PrintCustomerLabels, + ] + ); + assert_eq!( + PackDayPrintKind::PrintPackSheet.storage_key(), + "print_pack_sheet" + ); + assert_eq!( + PackDayPrintKind::PrintPickupRoster.storage_key(), + "print_pickup_roster" + ); + assert_eq!( + PackDayPrintKind::PrintCustomerLabels.storage_key(), + "print_customer_labels" + ); + assert_eq!( + PackDayPrintKind::PrintPackSheet.artifact_kind(), + PackDayExportArtifactKind::PackSheet + ); + assert_eq!( + PackDayPrintKind::PrintPickupRoster.artifact_kind(), + PackDayExportArtifactKind::PickupRoster + ); + assert_eq!( + PackDayPrintKind::PrintCustomerLabels.artifact_kind(), + PackDayExportArtifactKind::CustomerLabels + ); + assert_eq!(PackDayPrintKind::PrintPackSheet.label_stock(), None); + assert_eq!(PackDayPrintKind::PrintPickupRoster.label_stock(), None); + assert_eq!( + PackDayPrintKind::PrintCustomerLabels.label_stock(), + Some(PackDayPrintLabelStock::Avery5160Letter30Up) + ); + assert_eq!( + PackDayPrintLabelStock::all_v1(), + [PackDayPrintLabelStock::Avery5160Letter30Up] + ); + assert_eq!( + PackDayPrintLabelStock::Avery5160Letter30Up.storage_key(), + "avery_5160_letter_30_up" + ); + assert_eq!(PackDayPrintStatus::default(), PackDayPrintStatus::Idle); + assert_eq!(PackDayPrintStatus::Running.storage_key(), "running"); + assert_eq!(PackDayPrintStatus::Succeeded.storage_key(), "succeeded"); + assert_eq!(PackDayPrintStatus::Failed.storage_key(), "failed"); + assert_eq!( PackDayHostHandoffKind::all_v1(), [ PackDayHostHandoffKind::RevealBundle, @@ -3170,6 +3302,7 @@ mod tests { let fulfillment_window_id = FulfillmentWindowId::new(); let bundle = PackDayExportBundle { fulfillment_window_id, + export_instance_id: PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), artifacts: vec![ diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -14,13 +14,14 @@ use radroots_app_models::{ FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection, FarmSetupReadiness, FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection, - OrdersScreenQueryState, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus, - PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayProjection, PackDayScreenQueryState, - PersonalEntryProjection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, - ProductsListProjection, ProductsSort, RecoveryQueueProjection, ReminderFeedProjection, - ReminderLogProjection, SelectedSurfaceProjection, SettingsAccountProjection, - SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, + OrdersScreenQueryState, PackDayExportArtifactKind, PackDayExportBundle, + PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, + PackDayPrintKind, PackDayPrintLabelStock, PackDayPrintStatus, PackDayProjection, + PackDayScreenQueryState, PersonalEntryProjection, ProductEditorDraft, ProductId, + ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, + RecoveryQueueProjection, ReminderFeedProjection, ReminderLogProjection, + SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use radroots_app_sync::{ AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, SyncConflict, @@ -310,6 +311,7 @@ pub struct PackDayScreenProjection { pub query: PackDayScreenQueryState, pub projection: PackDayProjection, pub export: PackDayExportProjection, + pub print: PackDayPrintProjection, pub host_handoff: PackDayHostHandoffProjection, } @@ -317,6 +319,7 @@ impl PackDayScreenProjection { fn select_fulfillment_window(&mut self, fulfillment_window_id: Option<FulfillmentWindowId>) { if self.query.fulfillment_window_id != fulfillment_window_id { self.export = PackDayExportProjection::default(); + self.print = PackDayPrintProjection::default(); self.host_handoff = PackDayHostHandoffProjection::default(); } self.query.fulfillment_window_id = fulfillment_window_id; @@ -335,6 +338,7 @@ impl PackDayScreenProjection { if previous_window_id != next_window_id { self.export = PackDayExportProjection::default(); + self.print = PackDayPrintProjection::default(); self.host_handoff = PackDayHostHandoffProjection::default(); } @@ -343,11 +347,16 @@ impl PackDayScreenProjection { fn replace_export(&mut self, export: PackDayExportProjection) { if self.export != export { + self.print = PackDayPrintProjection::default(); self.host_handoff = PackDayHostHandoffProjection::default(); } self.export = export; } + fn replace_print(&mut self, print: PackDayPrintProjection) { + self.print = print; + } + fn replace_host_handoff(&mut self, host_handoff: PackDayHostHandoffProjection) { self.host_handoff = host_handoff; } @@ -406,6 +415,54 @@ impl PackDayExportProjection { } #[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackDayPrintRequest { + pub fulfillment_window_id: FulfillmentWindowId, + pub export_instance_id: PackDayExportInstanceId, + pub kind: PackDayPrintKind, + pub label_stock: Option<PackDayPrintLabelStock>, +} + +impl PackDayPrintRequest { + pub fn for_bundle(kind: PackDayPrintKind, bundle: &PackDayExportBundle) -> Self { + Self { + fulfillment_window_id: bundle.fulfillment_window_id, + export_instance_id: bundle.export_instance_id, + kind, + label_stock: kind.label_stock(), + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PackDayPrintProjection { + pub status: PackDayPrintStatus, + pub request: Option<PackDayPrintRequest>, +} + +impl PackDayPrintProjection { + pub fn running(request: PackDayPrintRequest) -> Self { + Self { + status: PackDayPrintStatus::Running, + request: Some(request), + } + } + + pub fn succeeded(request: PackDayPrintRequest) -> Self { + Self { + status: PackDayPrintStatus::Succeeded, + request: Some(request), + } + } + + pub fn failed(request: PackDayPrintRequest) -> Self { + Self { + status: PackDayPrintStatus::Failed, + request: Some(request), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct PackDayHostHandoffRequest { pub fulfillment_window_id: FulfillmentWindowId, pub kind: PackDayHostHandoffKind, @@ -906,6 +963,10 @@ pub enum AppStateCommand { message: String, }, ResetPackDayExport, + BeginPackDayPrint(PackDayPrintRequest), + SucceedPackDayPrint(PackDayPrintRequest), + FailPackDayPrint(PackDayPrintRequest), + ResetPackDayPrint, BeginPackDayHostHandoff(PackDayHostHandoffRequest), SucceedPackDayHostHandoff(PackDayHostHandoffRequest), FailPackDayHostHandoff { @@ -1057,6 +1118,22 @@ impl AppStateCommand { Self::ResetPackDayExport } + pub fn begin_pack_day_print(request: PackDayPrintRequest) -> Self { + Self::BeginPackDayPrint(request) + } + + pub fn succeed_pack_day_print(request: PackDayPrintRequest) -> Self { + Self::SucceedPackDayPrint(request) + } + + pub fn fail_pack_day_print(request: PackDayPrintRequest) -> Self { + Self::FailPackDayPrint(request) + } + + pub const fn reset_pack_day_print() -> Self { + Self::ResetPackDayPrint + } + pub fn begin_pack_day_host_handoff(request: PackDayHostHandoffRequest) -> Self { Self::BeginPackDayHostHandoff(request) } @@ -1588,6 +1665,26 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap .pack_day .replace_export(PackDayExportProjection::default()); } + AppStateCommand::BeginPackDayPrint(request) => { + projection + .pack_day + .replace_print(PackDayPrintProjection::running(request)); + } + AppStateCommand::SucceedPackDayPrint(request) => { + projection + .pack_day + .replace_print(PackDayPrintProjection::succeeded(request)); + } + AppStateCommand::FailPackDayPrint(request) => { + projection + .pack_day + .replace_print(PackDayPrintProjection::failed(request)); + } + AppStateCommand::ResetPackDayPrint => { + projection + .pack_day + .replace_print(PackDayPrintProjection::default()); + } AppStateCommand::BeginPackDayHostHandoff(request) => { projection .pack_day @@ -1942,9 +2039,9 @@ mod tests { AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute, InMemoryAppStateRepository, OrdersScreenProjection, PackDayExportProjection, PackDayExportRequest, PackDayHostHandoffProjection, PackDayHostHandoffRequest, - PackDayScreenProjection, PersistedAppState, ProductEditorState, ProductsScreenProjection, - ProductsScreenQueryState, SettingsPreference, derive_sync_projection, - derive_sync_run_status, + PackDayPrintProjection, PackDayPrintRequest, PackDayScreenProjection, PersistedAppState, + ProductEditorState, ProductsScreenProjection, ProductsScreenQueryState, SettingsPreference, + derive_sync_projection, derive_sync_run_status, }; use radroots_app_models::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, @@ -1953,14 +2050,15 @@ mod tests { LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, PackDayExportArtifact, - PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus, - PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, - PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, - PersonalEntryState, PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker, - ProductsFilter, ProductsListProjection, ProductsSort, ReminderDeliveryState, - ReminderFeedProjection, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, - SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, + PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, + PackDayPrintKind, PackDayPrintLabelStock, PackDayPrintStatus, PackDayProductTotalRow, + PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, PersonalEntryState, + PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, + ProductsListProjection, ProductsSort, ReminderDeliveryState, ReminderFeedProjection, + ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, SelectedAccountProjection, + SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection, + TodaySetupTask, TodaySetupTaskKind, }; use radroots_app_sync::{ AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, @@ -2013,11 +2111,20 @@ mod tests { PackDayHostHandoffRequest::for_bundle(kind, &bundle) } + fn sample_pack_day_print_request( + fulfillment_window_id: FulfillmentWindowId, + kind: PackDayPrintKind, + ) -> PackDayPrintRequest { + let bundle = sample_pack_day_export_bundle(fulfillment_window_id); + PackDayPrintRequest::for_bundle(kind, &bundle) + } + fn sample_pack_day_export_bundle( fulfillment_window_id: FulfillmentWindowId, ) -> PackDayExportBundle { PackDayExportBundle { fulfillment_window_id, + export_instance_id: PackDayExportInstanceId::new(), generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), artifacts: vec![ @@ -2396,6 +2503,13 @@ mod tests { } ); assert_eq!( + PackDayScreenProjection::default().print, + PackDayPrintProjection { + status: PackDayPrintStatus::Idle, + request: None, + } + ); + assert_eq!( PackDayScreenProjection::default().host_handoff, PackDayHostHandoffProjection { status: PackDayHostHandoffStatus::Idle, @@ -2461,6 +2575,61 @@ mod tests { } #[test] + fn pack_day_print_state_is_restart_ephemeral_and_skips_persistence() { + let mut store = + AppStateStore::load(FailingRepository).expect("failing repository should still load"); + let fulfillment_window_id = FulfillmentWindowId::new(); + let request = sample_pack_day_print_request( + fulfillment_window_id, + PackDayPrintKind::PrintCustomerLabels, + ); + + assert_eq!( + request.label_stock, + Some(PackDayPrintLabelStock::Avery5160Letter30Up) + ); + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_print(request.clone())), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().print, + PackDayPrintProjection::running(request.clone()) + ); + assert_eq!( + store.persisted_state().seller.pack_day_query, + PackDayScreenQueryState::default() + ); + + assert_eq!( + store.apply(AppStateCommand::succeed_pack_day_print(request.clone())), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().print, + PackDayPrintProjection::succeeded(request.clone()) + ); + + assert_eq!( + store.apply(AppStateCommand::fail_pack_day_print(request.clone())), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().print, + PackDayPrintProjection::failed(request) + ); + + assert_eq!( + store.apply(AppStateCommand::reset_pack_day_print()), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().print, + PackDayPrintProjection::default() + ); + } + + #[test] fn pack_day_host_handoff_state_is_restart_ephemeral_and_skips_persistence() { let mut store = AppStateStore::load(FailingRepository).expect("failing repository should still load"); @@ -2592,6 +2761,42 @@ mod tests { } #[test] + fn changing_pack_day_window_clears_stale_print_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let fulfillment_window_id = FulfillmentWindowId::new(); + let next_window_id = FulfillmentWindowId::new(); + let request = + sample_pack_day_print_request(fulfillment_window_id, PackDayPrintKind::PrintPackSheet); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_print(request)), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().print.status, + PackDayPrintStatus::Running + ); + + assert_eq!( + store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some( + next_window_id, + ))), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().query, + PackDayScreenQueryState { + fulfillment_window_id: Some(next_window_id), + } + ); + assert_eq!( + store.pack_day_projection().print, + PackDayPrintProjection::default() + ); + } + + #[test] fn changing_pack_day_export_state_clears_stale_host_handoff_state() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); @@ -2633,6 +2838,45 @@ mod tests { } #[test] + fn changing_pack_day_export_state_clears_stale_print_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let fulfillment_window_id = FulfillmentWindowId::new(); + let export_request = sample_pack_day_export_request(fulfillment_window_id); + let print_request = sample_pack_day_print_request( + fulfillment_window_id, + PackDayPrintKind::PrintPickupRoster, + ); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_export( + export_request.clone(), + )), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_print(print_request)), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().print.status, + PackDayPrintStatus::Running + ); + + assert_eq!( + store.apply(AppStateCommand::succeed_pack_day_export( + export_request, + sample_pack_day_export_bundle(fulfillment_window_id), + )), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().print, + PackDayPrintProjection::default() + ); + } + + #[test] fn replacing_pack_day_projection_with_new_window_clears_stale_host_handoff_state() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); @@ -2676,6 +2920,47 @@ mod tests { } #[test] + fn replacing_pack_day_projection_with_new_window_clears_stale_print_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let farm_id = FarmId::new(); + let current_window_id = FulfillmentWindowId::new(); + let next_window_id = FulfillmentWindowId::new(); + let request = + sample_pack_day_print_request(current_window_id, PackDayPrintKind::PrintCustomerLabels); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_print(request)), + Ok(true) + ); + + let next_pack_day = PackDayProjection { + fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { + fulfillment_window_id: next_window_id, + farm_id, + starts_at: "2026-04-25T16:00:00Z".to_owned(), + ends_at: "2026-04-25T19:00:00Z".to_owned(), + }), + totals_by_product: Vec::new(), + pack_list: Vec::new(), + pickup_roster: Vec::new(), + reminders: ReminderFeedProjection::default(), + }; + + assert_eq!( + store.apply(AppStateCommand::replace_pack_day_projection( + next_pack_day.clone(), + )), + Ok(true) + ); + assert_eq!(store.pack_day_projection().projection, next_pack_day); + assert_eq!( + store.pack_day_projection().print, + PackDayPrintProjection::default() + ); + } + + #[test] fn startup_identity_choice_flow_is_explicit_and_in_memory_only() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -221,6 +221,17 @@ "pack_day.export.folder.label": "Folder", "pack_day.export.files.label": "Files", "pack_day.export.error.label": "Error", + "pack_day.print.unavailable.title": "Print not available yet", + "pack_day.print.unavailable.body": "Print actions become available after pack day files are saved locally.", + "pack_day.print.pack_sheet.queued.title": "Queueing pack sheet", + "pack_day.print.pack_sheet.submitted.title": "Sent pack sheet to the printer", + "pack_day.print.pack_sheet.failed.title": "Couldn't print pack sheet", + "pack_day.print.pickup_roster.queued.title": "Queueing pickup roster", + "pack_day.print.pickup_roster.submitted.title": "Sent pickup roster to the printer", + "pack_day.print.pickup_roster.failed.title": "Couldn't print pickup roster", + "pack_day.print.customer_labels.queued.title": "Queueing customer labels", + "pack_day.print.customer_labels.submitted.title": "Sent customer labels to the printer", + "pack_day.print.customer_labels.failed.title": "Couldn't print customer labels", "pack_day.host_handoff.reveal.action": "Show in Finder", "pack_day.host_handoff.reveal.action.running": "Showing in Finder...", "pack_day.host_handoff.open_pack_sheet.action": "Open pack sheet",