app

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

commit 6d1fcaaa840a7c4d4d1d129992a18cbf2cc7773e
parent dad32c7686f4757cb01a2d1452c4d6ae3fb58095
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 23:37:33 +0000

runtime: derive seller reminder projections

Diffstat:
MCargo.lock | 1+
Mcrates/launchers/desktop/Cargo.toml | 1+
Mcrates/launchers/desktop/src/runtime.rs | 792+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/shared/state/src/lib.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 795 insertions(+), 61 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5055,6 +5055,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -28,6 +28,7 @@ radroots_app_ui.workspace = true serde_json.workspace = true thiserror.workspace = true tracing.workspace = true +uuid.workspace = true [dev-dependencies] tracing-subscriber.workspace = true diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -3,7 +3,7 @@ use std::fmt; use std::path::PathBuf; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use chrono::Utc; +use chrono::{Duration, Utc}; use radroots_app_core::{ AppBuildIdentity, AppDesktopRuntimePaths, AppRuntimeCapture, AppRuntimeMode, AppRuntimePathsError, AppRuntimeSnapshot, AppSharedAccountsPaths, @@ -14,11 +14,13 @@ use radroots_app_models::{ BuyerCheckoutDraft, BuyerContext, BuyerOrderDetailProjection, BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, - LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection, - OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PersonalSection, - PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, - ProductsSort, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, + LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrderRecoveryProjection, + OrdersFilter, OrdersListProjection, OrdersScreenQueryState, PackDayProjection, + PackDayScreenQueryState, PersonalSection, PickupLocationRecord, ProductEditorDraft, ProductId, + ProductsFilter, ProductsListProjection, ProductsSort, RecoveryKind, RecoveryQueueProjection, + RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, + ReminderId, ReminderKind, ReminderSurface, ReminderUrgency, SettingsAccountProjection, + SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, @@ -44,6 +46,7 @@ use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; use serde_json::json; use thiserror::Error; use tracing::error; +use uuid::Uuid; use crate::accounts::{ DesktopAccountsBootstrapError, DesktopAccountsCommandError, DesktopAccountsProjectionError, @@ -676,10 +679,31 @@ struct DesktopSelectedAccountContext { today_projection: TodayAgendaProjection, products_list: ProductsListProjection, orders_list: OrdersListProjection, + orders_reminders: ReminderFeedProjection, + recovery_queue: RecoveryQueueProjection, order_detail: Option<OrderDetailProjection>, pack_day_projection: PackDayProjection, } +#[derive(Clone, Debug, Default)] +struct DesktopSellerReminderContext { + today_feed: ReminderFeedProjection, + orders_feed: ReminderFeedProjection, + pack_day_feed: ReminderFeedProjection, + recovery_queue: RecoveryQueueProjection, + selected_order_recovery: Option<OrderRecoveryProjection>, + due_soon_count: u32, + recovery_actions_open: u32, +} + +#[derive(Clone, Debug, Default)] +struct DesktopReminderSyncTruth { + checkpoint: SyncCheckpointStatus, + pending_write_count: usize, + unresolved_conflict_count: usize, + blocking_conflict_count: usize, +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] struct DesktopSelectedAccountSyncContext { projection: AppSyncProjection, @@ -818,6 +842,12 @@ impl DesktopAppRuntimeState { let _ = state_store.apply_in_memory(AppStateCommand::replace_orders_list( selected_account_context.orders_list, )); + let _ = state_store.apply_in_memory(AppStateCommand::replace_orders_reminders( + selected_account_context.orders_reminders, + )); + let _ = state_store.apply_in_memory(AppStateCommand::replace_orders_recovery_queue( + selected_account_context.recovery_queue, + )); let _ = state_store.apply_in_memory(AppStateCommand::replace_order_detail( selected_account_context.order_detail, )); @@ -1384,9 +1414,6 @@ impl DesktopAppRuntimeState { fn open_orders_query(&mut self, query: OrdersScreenQueryState) -> Result<bool, AppSqliteError> { let query_changed = self.replace_orders_query(query)?; - let detail_changed = self - .state_store - .apply_in_memory(AppStateCommand::replace_order_detail(None)); let section_changed = self .state_store .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( @@ -1394,7 +1421,7 @@ impl DesktopAppRuntimeState { ))); let editor_changed = self.close_product_editor(); - Ok(query_changed || detail_changed || section_changed || editor_changed) + Ok(query_changed || section_changed || editor_changed) } fn open_order_detail(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { @@ -1404,13 +1431,18 @@ impl DesktopAppRuntimeState { let Some(farm_id) = self.selected_farm_id() else { return Ok(false); }; - let Some(order_detail) = sqlite_store.load_order_detail(farm_id, order_id)? else { + let Some(_) = sqlite_store.load_order_detail(farm_id, order_id)? else { return Ok(false); }; - - let detail_changed = self - .state_store - .apply_in_memory(AppStateCommand::replace_order_detail(Some(order_detail))); + let selected_account_context = load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + Some(order_id), + self.state_store.pack_day_projection().query.clone(), + )?; + let detail_changed = self.apply_selected_account_context(&selected_account_context); let section_changed = self .state_store .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( @@ -1906,11 +1938,29 @@ impl DesktopAppRuntimeState { } fn apply_selected_account_context(&mut self, context: &DesktopSelectedAccountContext) -> bool { - let personal_changed = + self.apply_selected_account_context_with_options(context, true) + } + + fn apply_selected_account_seller_context( + &mut self, + context: &DesktopSelectedAccountContext, + ) -> bool { + self.apply_selected_account_context_with_options(context, false) + } + + fn apply_selected_account_context_with_options( + &mut self, + context: &DesktopSelectedAccountContext, + include_personal: bool, + ) -> bool { + let personal_changed = if include_personal { self.state_store .apply_in_memory(AppStateCommand::replace_personal_projection( context.personal_projection.clone(), - )); + )) + } else { + false + }; let farm_setup_changed = self.state_store .apply_in_memory(AppStateCommand::replace_farm_setup_projection( @@ -1936,6 +1986,16 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::replace_orders_list( context.orders_list.clone(), )); + let orders_reminders_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_orders_reminders( + context.orders_reminders.clone(), + )); + let recovery_queue_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_orders_recovery_queue( + context.recovery_queue.clone(), + )); let order_detail_changed = self.state_store .apply_in_memory(AppStateCommand::replace_order_detail( @@ -1959,6 +2019,8 @@ impl DesktopAppRuntimeState { || today_changed || products_changed || orders_changed + || orders_reminders_changed + || recovery_queue_changed || order_detail_changed || pack_day_changed || editor_changed @@ -1996,8 +2058,23 @@ impl DesktopAppRuntimeState { fn refresh_selected_account_sync(&mut self) -> Result<bool, AppSqliteError> { let context = self.refresh_selected_account_sync_context()?; + let sync_changed = self.apply_selected_account_sync_context(&context); + let selected_account_changed = match self.sqlite_store.as_ref() { + Some(sqlite_store) => { + let selected_account_context = load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), + )?; + self.apply_selected_account_seller_context(&selected_account_context) + } + None => false, + }; - Ok(self.apply_selected_account_sync_context(&context)) + Ok(sync_changed || selected_account_changed) } fn resolve_sync_conflict( @@ -2504,7 +2581,6 @@ impl DesktopAppRuntimeState { &mut self, query: OrdersScreenQueryState, ) -> Result<bool, AppSqliteError> { - let orders_list = self.load_orders_list_for_query(&query)?; let filter_changed = self .state_store .apply_in_memory(AppStateCommand::select_orders_filter(query.filter)); @@ -2513,60 +2589,46 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::select_orders_fulfillment_window( query.fulfillment_window_id, )); - let list_changed = self - .state_store - .apply_in_memory(AppStateCommand::replace_orders_list(orders_list)); - - Ok(filter_changed || fulfillment_window_changed || list_changed) - } - - fn load_orders_list_for_query( - &self, - query: &OrdersScreenQueryState, - ) -> Result<OrdersListProjection, AppSqliteError> { let Some(sqlite_store) = self.sqlite_store.as_ref() else { - return Ok(OrdersListProjection::default()); - }; - let Some(farm_id) = self.selected_farm_id() else { - return Ok(OrdersListProjection::default()); + return Ok(filter_changed || fulfillment_window_changed); }; + let selected_account_context = load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + query, + None, + self.state_store.pack_day_projection().query.clone(), + )?; + let context_changed = self.apply_selected_account_context(&selected_account_context); - sqlite_store.load_orders_list(farm_id, query) + Ok(filter_changed || fulfillment_window_changed || context_changed) } fn replace_pack_day_query( &mut self, query: PackDayScreenQueryState, ) -> Result<bool, AppSqliteError> { - let pack_day_projection = self.load_pack_day_for_query(&query)?; let fulfillment_window_changed = self.state_store .apply_in_memory(AppStateCommand::set_pack_day_fulfillment_window( query.fulfillment_window_id, )); - let projection_changed = - self.state_store - .apply_in_memory(AppStateCommand::replace_pack_day_projection( - pack_day_projection, - )); - - Ok(fulfillment_window_changed || projection_changed) - } - - fn load_pack_day_for_query( - &self, - query: &PackDayScreenQueryState, - ) -> Result<PackDayProjection, AppSqliteError> { let Some(sqlite_store) = self.sqlite_store.as_ref() else { - return Ok(PackDayProjection::default()); - }; - let Some(farm_id) = self.selected_farm_id() else { - return Ok(PackDayProjection::default()); + return Ok(fulfillment_window_changed); }; + let selected_account_context = load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + query, + )?; + let context_changed = self.apply_selected_account_context(&selected_account_context); - sqlite_store.load_pack_day(farm_id, query) + Ok(fulfillment_window_changed || context_changed) } - fn sync_truthful_farmer_section(&mut self) -> bool { let selected_section = self.state_store.shell_projection().selected_section; let should_reset_to_today = match selected_section { @@ -2763,6 +2825,9 @@ fn load_selected_account_context( let Some(selected_account) = identity_projection.selected_account.as_ref() else { return Ok(DesktopSelectedAccountContext { personal_projection, + orders_list: OrdersListProjection::default(), + orders_reminders: ReminderFeedProjection::default(), + recovery_queue: RecoveryQueueProjection::default(), ..DesktopSelectedAccountContext::default() }); }; @@ -2785,7 +2850,7 @@ fn load_selected_account_context( } None => FarmRulesProjection::default(), }; - let today_projection = match today_farm_id { + let mut today_projection = match today_farm_id { Some(farm_id) => sqlite_store.load_today_agenda(Some(farm_id))?, None => TodayAgendaProjection::default(), }; @@ -2802,14 +2867,51 @@ fn load_selected_account_context( Some(farm_id) => sqlite_store.load_orders_list(farm_id, &orders_query)?, None => OrdersListProjection::default(), }; - let order_detail = match today_farm_id.zip(selected_order_id) { + let canonical_orders_list = match today_farm_id { + Some(farm_id) => { + sqlite_store.load_orders_list(farm_id, &OrdersScreenQueryState::default())? + } + None => OrdersListProjection::default(), + }; + let mut order_detail = match today_farm_id.zip(selected_order_id) { Some((farm_id, order_id)) => sqlite_store.load_order_detail(farm_id, order_id)?, None => None, }; - let pack_day_projection = match today_farm_id { + let mut pack_day_projection = match today_farm_id { Some(farm_id) => sqlite_store.load_pack_day(farm_id, &pack_day_query)?, None => PackDayProjection::default(), }; + let (orders_reminders, recovery_queue) = match today_farm_id { + Some(farm_id) => { + let reminder_context = load_selected_account_reminder_context( + sqlite_store, + selected_account.account.account_id.as_str(), + farm_id, + &today_projection, + &canonical_orders_list, + &pack_day_projection, + order_detail.as_ref(), + )?; + today_projection.reminders = reminder_context.today_feed; + if let Some(summary) = today_projection.summary.as_mut() { + summary.reminders_due_soon = reminder_context.due_soon_count; + summary.recovery_actions_open = reminder_context.recovery_actions_open; + } + if let Some(detail) = order_detail.as_mut() { + detail.recovery = reminder_context.selected_order_recovery; + } + pack_day_projection.reminders = reminder_context.pack_day_feed; + + ( + reminder_context.orders_feed, + reminder_context.recovery_queue, + ) + } + None => ( + ReminderFeedProjection::default(), + RecoveryQueueProjection::default(), + ), + }; Ok(DesktopSelectedAccountContext { personal_projection, @@ -2818,11 +2920,408 @@ fn load_selected_account_context( today_projection, products_list, orders_list, + orders_reminders, + recovery_queue, order_detail, pack_day_projection, }) } +fn load_selected_account_reminder_context( + sqlite_store: &AppSqliteStore, + account_id: &str, + farm_id: FarmId, + today_projection: &TodayAgendaProjection, + canonical_orders_list: &OrdersListProjection, + pack_day_projection: &PackDayProjection, + selected_order_detail: Option<&OrderDetailProjection>, +) -> Result<DesktopSellerReminderContext, AppSqliteError> { + let existing_schedule = sqlite_store.load_reminder_schedule(account_id, farm_id)?; + let recovery_queue = sqlite_store.load_recovery_queue(account_id, farm_id)?; + let sync_truth = load_selected_account_reminder_sync_truth(sqlite_store, account_id)?; + let schedule = derive_selected_account_reminder_schedule( + farm_id, + today_projection, + canonical_orders_list, + pack_day_projection, + &recovery_queue, + &sync_truth, + &existing_schedule, + ); + if schedule != existing_schedule { + sqlite_store.replace_reminder_schedule(account_id, farm_id, &schedule)?; + } + + let selected_order_recovery = selected_order_detail.and_then(|detail| { + recovery_queue + .items + .iter() + .find(|record| record.order_id == detail.order_id) + .cloned() + }); + let due_soon_count = schedule + .items + .iter() + .filter(|item| { + !matches!( + item.kind, + ReminderKind::MissedPickupRecovery | ReminderKind::RefundRecovery + ) && matches!( + item.urgency, + ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking + ) + }) + .count() as u32; + let recovery_actions_open = recovery_queue + .items + .iter() + .filter(|record| record.state != RecoveryState::Resolved) + .count() as u32; + + Ok(DesktopSellerReminderContext { + today_feed: filter_reminder_surface(&schedule, ReminderSurface::Today), + orders_feed: filter_reminder_surface(&schedule, ReminderSurface::Orders), + pack_day_feed: filter_reminder_surface(&schedule, ReminderSurface::PackDay), + recovery_queue, + selected_order_recovery, + due_soon_count, + recovery_actions_open, + }) +} + +fn load_selected_account_reminder_sync_truth( + sqlite_store: &AppSqliteStore, + account_id: &str, +) -> Result<DesktopReminderSyncTruth, AppSqliteError> { + let checkpoint = sqlite_store.load_sync_checkpoint(account_id)?; + let conflicts = sqlite_store.load_sync_conflicts(account_id)?; + let pending_write_count = sqlite_store.load_pending_sync_operations(account_id)?.len(); + let unresolved_conflict_count = conflicts + .iter() + .filter(|stored| stored.conflict.is_unresolved()) + .count(); + let blocking_conflict_count = conflicts + .iter() + .filter(|stored| { + stored.conflict.is_unresolved() + && matches!(stored.conflict.severity, SyncConflictSeverity::Blocking) + }) + .count(); + + Ok(DesktopReminderSyncTruth { + checkpoint, + pending_write_count, + unresolved_conflict_count, + blocking_conflict_count, + }) +} + +fn derive_selected_account_reminder_schedule( + farm_id: FarmId, + today_projection: &TodayAgendaProjection, + canonical_orders_list: &OrdersListProjection, + pack_day_projection: &PackDayProjection, + recovery_queue: &RecoveryQueueProjection, + sync_truth: &DesktopReminderSyncTruth, + existing_schedule: &ReminderFeedProjection, +) -> ReminderFeedProjection { + let mut items = Vec::new(); + + if let Some(window) = today_projection.next_fulfillment_window.as_ref() { + items.push(build_reminder_projection( + farm_id, + format!( + "reminder:today:fulfillment_window:{}", + window.fulfillment_window_id + ), + None, + Some(window.fulfillment_window_id), + ReminderKind::FulfillmentWindow, + ReminderSurface::Today, + "Prepare the next fulfillment window".to_owned(), + format!( + "The next fulfillment window starts at {}.", + window.starts_at + ), + window.starts_at.clone(), + Some("Open pack day".to_owned()), + None, + existing_schedule, + )); + } + + if canonical_orders_list.summary.needs_action_orders > 0 { + let deadline_at = today_projection + .next_fulfillment_window + .as_ref() + .map(|window| window.starts_at.clone()) + .unwrap_or_else(current_utc_timestamp); + let detail = canonical_orders_list + .rows + .first() + .and_then(|row| row.fulfillment_window_label.as_ref()) + .map(|label| { + format!( + "{} order(s) still need review before {}.", + canonical_orders_list.summary.needs_action_orders, label + ) + }) + .unwrap_or_else(|| { + format!( + "{} order(s) still need review.", + canonical_orders_list.summary.needs_action_orders + ) + }); + items.push(build_reminder_projection( + farm_id, + "reminder:orders:needs_action".to_owned(), + canonical_orders_list.rows.first().map(|row| row.order_id), + canonical_orders_list + .rows + .first() + .and_then(|row| row.fulfillment_window_id), + ReminderKind::OrderAction, + ReminderSurface::Orders, + "Review open orders".to_owned(), + detail, + deadline_at, + Some("Review".to_owned()), + None, + existing_schedule, + )); + } + + if let Some(window) = pack_day_projection.fulfillment_window.as_ref() { + items.push(build_reminder_projection( + farm_id, + format!( + "reminder:pack_day:fulfillment_window:{}", + window.fulfillment_window_id + ), + None, + Some(window.fulfillment_window_id), + ReminderKind::FulfillmentWindow, + ReminderSurface::PackDay, + "Pack for this fulfillment window".to_owned(), + format!("Packing needs to be ready before {}.", window.starts_at), + window.starts_at.clone(), + Some("Review pack day".to_owned()), + None, + existing_schedule, + )); + } + + if let Some(sync_reminder) = + build_sync_reminder_projection(farm_id, sync_truth, existing_schedule) + { + items.push(sync_reminder); + } + + for record in recovery_queue + .items + .iter() + .filter(|record| record.state != RecoveryState::Resolved) + { + let kind = match record.kind { + RecoveryKind::MissedPickup => ReminderKind::MissedPickupRecovery, + RecoveryKind::RefundFollowUp => ReminderKind::RefundRecovery, + }; + items.push(build_reminder_projection( + farm_id, + format!( + "reminder:orders:recovery:{}:{}", + record.kind.storage_key(), + record.order_id + ), + Some(record.order_id), + None, + kind, + ReminderSurface::Orders, + record.summary.clone(), + record + .note + .clone() + .unwrap_or_else(|| "Recovery follow-up is still open.".to_owned()), + record.last_updated_at.clone(), + Some("Review".to_owned()), + None, + existing_schedule, + )); + } + + items.sort_by(|left, right| { + left.deadline_at.cmp(&right.deadline_at).then_with(|| { + left.reminder_id + .to_string() + .cmp(&right.reminder_id.to_string()) + }) + }); + + ReminderFeedProjection { items } +} + +fn build_sync_reminder_projection( + farm_id: FarmId, + sync_truth: &DesktopReminderSyncTruth, + existing_schedule: &ReminderFeedProjection, +) -> Option<ReminderDeadlineProjection> { + if sync_truth.blocking_conflict_count > 0 { + return Some(build_reminder_projection( + farm_id, + "reminder:orders:sync:blocking_conflicts".to_owned(), + None, + None, + ReminderKind::SyncImpact, + ReminderSurface::Orders, + "Resolve blocking sync conflicts".to_owned(), + format!( + "{} blocking sync conflict(s) need review before the next sync run.", + sync_truth.blocking_conflict_count + ), + current_utc_timestamp(), + Some("Review".to_owned()), + Some(ReminderUrgency::Blocking), + existing_schedule, + )); + } + + if sync_truth.unresolved_conflict_count > 0 { + return Some(build_reminder_projection( + farm_id, + "reminder:orders:sync:conflicts".to_owned(), + None, + None, + ReminderKind::SyncImpact, + ReminderSurface::Orders, + "Review sync conflicts".to_owned(), + format!( + "{} sync conflict(s) are still unresolved.", + sync_truth.unresolved_conflict_count + ), + current_utc_timestamp(), + Some("Review".to_owned()), + Some(ReminderUrgency::DueSoon), + existing_schedule, + )); + } + + if sync_truth.checkpoint.is_failed() { + return Some(build_reminder_projection( + farm_id, + "reminder:orders:sync:failed".to_owned(), + None, + None, + ReminderKind::SyncImpact, + ReminderSurface::Orders, + "Retry sync".to_owned(), + sync_truth + .checkpoint + .last_error_message + .clone() + .unwrap_or_else(|| "The last sync attempt failed.".to_owned()), + current_utc_timestamp(), + Some("Review".to_owned()), + Some(ReminderUrgency::Blocking), + existing_schedule, + )); + } + + if sync_truth.pending_write_count > 0 { + return Some(build_reminder_projection( + farm_id, + "reminder:orders:sync:pending".to_owned(), + None, + None, + ReminderKind::SyncImpact, + ReminderSurface::Orders, + "Pending local changes".to_owned(), + format!( + "{} local change(s) are waiting to sync.", + sync_truth.pending_write_count + ), + current_utc_timestamp(), + Some("Review".to_owned()), + Some(ReminderUrgency::Upcoming), + existing_schedule, + )); + } + + None +} + +fn build_reminder_projection( + farm_id: FarmId, + identity_key: String, + order_id: Option<OrderId>, + fulfillment_window_id: Option<FulfillmentWindowId>, + kind: ReminderKind, + surface: ReminderSurface, + title: String, + detail: String, + deadline_at: String, + action_label: Option<String>, + urgency_override: Option<ReminderUrgency>, + existing_schedule: &ReminderFeedProjection, +) -> ReminderDeadlineProjection { + let reminder_id = stable_reminder_id(identity_key.as_str()); + let urgency = urgency_override.unwrap_or_else(|| reminder_urgency(deadline_at.as_str())); + let delivery_state = existing_schedule + .items + .iter() + .find(|item| item.reminder_id == reminder_id) + .map(|item| item.delivery_state) + .unwrap_or(ReminderDeliveryState::Scheduled); + + ReminderDeadlineProjection { + reminder_id, + farm_id, + order_id, + fulfillment_window_id, + kind, + surface, + urgency, + title, + detail, + deadline_at, + action_label, + delivery_state, + } +} + +fn stable_reminder_id(identity_key: &str) -> ReminderId { + ReminderId::from(Uuid::new_v5(&Uuid::NAMESPACE_URL, identity_key.as_bytes())) +} + +fn reminder_urgency(deadline_at: &str) -> ReminderUrgency { + let Ok(deadline) = chrono::DateTime::parse_from_rfc3339(deadline_at) else { + return ReminderUrgency::Upcoming; + }; + let deadline = deadline.with_timezone(&Utc); + let now = Utc::now(); + + if deadline <= now { + ReminderUrgency::Overdue + } else if deadline <= now + Duration::hours(48) { + ReminderUrgency::DueSoon + } else { + ReminderUrgency::Upcoming + } +} + +fn filter_reminder_surface( + schedule: &ReminderFeedProjection, + surface: ReminderSurface, +) -> ReminderFeedProjection { + ReminderFeedProjection { + items: schedule + .items + .iter() + .filter(|item| item.surface == surface) + .cloned() + .collect(), + } +} + fn load_selected_account_sync_context( sqlite_store: &AppSqliteStore, identity_projection: &AppIdentityProjection, @@ -3219,6 +3718,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + use chrono::{Duration, Utc}; use radroots_app_core::{ AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, AppSharedAccountsPaths, SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, @@ -3231,8 +3731,9 @@ mod tests { FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, PersonalSection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductStatus, ProductsFilter, - ProductsSort, SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + ProductsSort, RecoveryKind, RecoveryRecordId, ReminderKind, SelectedSurfaceProjection, + SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, + TodaySetupTaskKind, TodaySummary, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -5381,6 +5882,177 @@ mod tests { } #[test] + fn runtime_threads_canonical_seller_reminders_across_today_orders_and_pack_day() { + let runtime = memory_runtime(); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + seed_order_workspace(&runtime, farm_id); + + assert!(runtime.open_orders().expect("orders should open")); + let summary = runtime.summary(); + + assert_eq!(summary.today_projection.reminders.items.len(), 1); + assert_eq!( + summary.today_projection.reminders.items[0].kind, + ReminderKind::FulfillmentWindow + ); + assert_eq!(summary.orders_projection.reminders.items.len(), 1); + assert_eq!( + summary.orders_projection.reminders.items[0].kind, + ReminderKind::OrderAction + ); + assert_eq!( + summary.pack_day_projection.projection.reminders.items.len(), + 1 + ); + assert_eq!( + summary.pack_day_projection.projection.reminders.items[0].kind, + ReminderKind::FulfillmentWindow + ); + assert_eq!( + summary + .today_projection + .summary + .as_ref() + .expect("today summary") + .recovery_actions_open, + 0 + ); + } + + #[test] + fn runtime_sync_refresh_threads_sync_reminders_into_orders_projection() { + let runtime = memory_runtime(); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (_, order_id) = seed_order_workspace(&runtime, farm_id); + let sql = format!( + "update orders + set status = 'scheduled', updated_at = '2026-04-17T12:00:00Z' + where id = '{order_id}' and farm_id = '{farm_id}'" + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&sql) + .expect("order should update to scheduled"); + + assert!(runtime.open_orders().expect("orders should open")); + assert!( + runtime + .mark_order_packed(order_id) + .expect("mark packed should succeed") + ); + let summary = runtime.summary(); + + assert_eq!(summary.sync_status.pending_write_count, 1); + assert!( + summary + .orders_projection + .reminders + .items + .iter() + .any(|item| item.kind == ReminderKind::SyncImpact + && item.title == "Pending local changes") + ); + } + + #[test] + fn runtime_threads_recovery_queue_into_today_counts_and_order_detail() { + let runtime = memory_runtime(); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (_, order_id) = seed_order_workspace(&runtime, farm_id); + let recovery_record_id = RecoveryRecordId::new(); + let sql = format!( + "insert into order_recovery_records ( + recovery_record_id, + account_id, + farm_id, + order_id, + recovery_kind, + recovery_state, + summary, + note, + last_updated_at + ) values ( + '{recovery_record_id}', + '{}', + '{farm_id}', + '{order_id}', + 'missed_pickup', + 'open', + 'Follow up on the missed pickup', + 'Confirm a new pickup time.', + '2026-04-18T18:30:00Z' + )", + runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&sql) + .expect("recovery record should seed"); + + assert!( + runtime + .open_order_detail(order_id) + .expect("order detail should open") + ); + let summary = runtime.summary(); + + assert_eq!(summary.orders_projection.recovery_queue.items.len(), 1); + assert_eq!( + summary + .today_projection + .summary + .as_ref() + .expect("today summary") + .recovery_actions_open, + 1 + ); + assert_eq!( + summary + .orders_projection + .detail + .as_ref() + .and_then(|detail| detail.recovery.as_ref()) + .expect("order recovery") + .kind, + RecoveryKind::MissedPickup + ); + } + + #[test] + fn reminder_urgency_marks_due_soon_and_overdue_deadlines() { + let due_soon = (Utc::now() + Duration::hours(24)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + let overdue = (Utc::now() - Duration::hours(2)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + + assert_eq!( + super::reminder_urgency(due_soon.as_str()), + super::ReminderUrgency::DueSoon + ); + assert_eq!( + super::reminder_urgency(overdue.as_str()), + super::ReminderUrgency::Overdue + ); + } + + #[test] fn runtime_open_orders_resets_to_default_queue_and_clears_detail() { let runtime = memory_runtime(); let (_, farm_id) = provision_ready_farmer_account(&runtime); diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -552,6 +552,8 @@ pub enum AppStateCommand { SelectOrdersFilter(OrdersFilter), SelectOrdersFulfillmentWindow(Option<FulfillmentWindowId>), ReplaceOrdersList(OrdersListProjection), + ReplaceOrdersReminders(ReminderFeedProjection), + ReplaceOrdersRecoveryQueue(RecoveryQueueProjection), ReplaceOrderDetail(Option<OrderDetailProjection>), SetPackDayFulfillmentWindow(Option<FulfillmentWindowId>), ReplacePackDayProjection(PackDayProjection), @@ -651,6 +653,14 @@ impl AppStateCommand { Self::ReplaceOrdersList(projection) } + pub fn replace_orders_reminders(projection: ReminderFeedProjection) -> Self { + Self::ReplaceOrdersReminders(projection) + } + + pub fn replace_orders_recovery_queue(projection: RecoveryQueueProjection) -> Self { + Self::ReplaceOrdersRecoveryQueue(projection) + } + pub fn replace_order_detail(projection: Option<OrderDetailProjection>) -> Self { Self::ReplaceOrderDetail(projection) } @@ -1085,6 +1095,12 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateCommand::ReplaceOrdersList(orders_projection) => { projection.orders.list = orders_projection; } + AppStateCommand::ReplaceOrdersReminders(reminders_projection) => { + projection.orders.reminders = reminders_projection; + } + AppStateCommand::ReplaceOrdersRecoveryQueue(recovery_queue_projection) => { + projection.orders.recovery_queue = recovery_queue_projection; + } AppStateCommand::ReplaceOrderDetail(order_detail_projection) => { projection.orders.replace_detail(order_detail_projection); } @@ -1649,6 +1665,33 @@ mod tests { primary_action: Some(OrderPrimaryAction::Review), recovery: None, }; + let orders_reminders = ReminderFeedProjection { + items: vec![radroots_app_models::ReminderDeadlineProjection { + reminder_id: radroots_app_models::ReminderId::new(), + farm_id, + order_id: Some(order_id), + fulfillment_window_id: Some(fulfillment_window_id), + kind: radroots_app_models::ReminderKind::OrderAction, + surface: radroots_app_models::ReminderSurface::Orders, + urgency: radroots_app_models::ReminderUrgency::DueSoon, + title: "review order".to_owned(), + detail: "Casey still needs confirmation.".to_owned(), + deadline_at: "2026-04-18T15:00:00Z".to_owned(), + action_label: Some("Review".to_owned()), + delivery_state: radroots_app_models::ReminderDeliveryState::Scheduled, + }], + }; + let recovery_queue = radroots_app_models::RecoveryQueueProjection { + items: vec![radroots_app_models::OrderRecoveryProjection { + recovery_record_id: radroots_app_models::RecoveryRecordId::new(), + order_id, + kind: radroots_app_models::RecoveryKind::MissedPickup, + state: radroots_app_models::RecoveryState::Open, + summary: "Follow up on pickup".to_owned(), + note: None, + last_updated_at: "2026-04-18T19:00:00Z".to_owned(), + }], + }; let pack_day = PackDayProjection { fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { fulfillment_window_id, @@ -1696,6 +1739,18 @@ mod tests { Ok(true) ); assert_eq!( + store.apply(AppStateCommand::replace_orders_reminders( + orders_reminders.clone() + )), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::replace_orders_recovery_queue( + recovery_queue.clone() + )), + Ok(true) + ); + assert_eq!( store.apply(AppStateCommand::replace_order_detail(Some( order_detail.clone() ))), @@ -1721,6 +1776,8 @@ mod tests { } ); assert_eq!(store.projection().orders.list, orders_list); + assert_eq!(store.projection().orders.reminders, orders_reminders); + assert_eq!(store.projection().orders.recovery_queue, recovery_queue); assert_eq!(store.projection().orders.detail, Some(order_detail)); assert_eq!( store.projection().pack_day.query, @@ -2498,6 +2555,9 @@ mod tests { assert!(projection.orders.recovery_queue.is_empty()); assert!(projection.reminder_log.is_empty()); assert!(projection.pack_day.projection.reminders.is_empty()); - assert_eq!(projection.orders.reminders, ReminderFeedProjection::default()); + assert_eq!( + projection.orders.reminders, + ReminderFeedProjection::default() + ); } }