app

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

commit a6ec9e0ec6ba119397a0eee6888344d6b520c97f
parent 812700f1bd7bf64ab44e93df6febd13bf18caea6
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 00:25:56 +0000

runtime: add reminder log and local reminder presentation

Diffstat:
Mcrates/launchers/desktop/src/app.rs | 1+
Mcrates/launchers/desktop/src/runtime.rs | 390+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/launchers/desktop/src/source_guards.rs | 14++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 444++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/shared/i18n/src/keys.rs | 8++++++++
Mcrates/shared/i18n/src/lib.rs | 20++++++++++++++++++++
Mcrates/shared/sqlite/src/lib.rs | 45++++++++++++++++++++++++++++++++++++++++-----
Mcrates/shared/sqlite/src/reminders.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/shared/state/src/lib.rs | 32+++++++++++++++++++++++++++++++-
Mi18n/locales/en/messages.json | 8++++++++
10 files changed, 1024 insertions(+), 50 deletions(-)

diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -286,6 +286,7 @@ mod tests { products_projection: Default::default(), orders_projection: Default::default(), pack_day_projection: Default::default(), + reminder_log: Default::default(), runtime_metadata: crate::runtime::DesktopAppRuntimeMetadataSummary::default(), logged_out_startup: LoggedOutStartupProjection::default(), sync_status: crate::runtime::DesktopAppSyncStatusSummary::default(), diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -19,8 +19,9 @@ use radroots_app_models::{ PackDayScreenQueryState, PersonalSection, PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, - ReminderId, ReminderKind, ReminderSurface, ReminderUrgency, SettingsAccountProjection, - SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, + ReminderId, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, + ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, + TodayAgendaProjection, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, @@ -126,6 +127,7 @@ impl DesktopAppRuntime { products_projection: state.state_store.products_projection().clone(), orders_projection: state.state_store.orders_projection().clone(), pack_day_projection: state.state_store.pack_day_projection().clone(), + reminder_log: state.state_store.reminder_log_projection().clone(), runtime_metadata: state.runtime_metadata.clone(), sync_status, startup_issue: state.startup_issue.clone(), @@ -509,6 +511,10 @@ impl DesktopAppRuntime { .resolve_sync_conflict(conflict_id, resolution) } + pub fn acknowledge_reminder(&self, reminder_id: ReminderId) -> Result<bool, AppSqliteError> { + self.lock_state_mut().acknowledge_reminder(reminder_id) + } + pub fn record_home_opened(&self) -> bool { self.record_activity(AppActivityKind::HomeOpened) } @@ -658,6 +664,7 @@ pub struct DesktopAppRuntimeSummary { pub products_projection: ProductsScreenProjection, pub orders_projection: OrdersScreenProjection, pub pack_day_projection: PackDayScreenProjection, + pub reminder_log: ReminderLogProjection, pub runtime_metadata: DesktopAppRuntimeMetadataSummary, pub sync_status: DesktopAppSyncStatusSummary, pub startup_issue: Option<String>, @@ -683,6 +690,7 @@ struct DesktopSelectedAccountContext { recovery_queue: RecoveryQueueProjection, order_detail: Option<OrderDetailProjection>, pack_day_projection: PackDayProjection, + reminder_log: ReminderLogProjection, } #[derive(Clone, Debug, Default)] @@ -694,6 +702,7 @@ struct DesktopSellerReminderContext { selected_order_recovery: Option<OrderRecoveryProjection>, due_soon_count: u32, recovery_actions_open: u32, + reminder_log: ReminderLogProjection, } #[derive(Clone, Debug, Default)] @@ -848,6 +857,9 @@ impl DesktopAppRuntimeState { let _ = state_store.apply_in_memory(AppStateCommand::replace_orders_recovery_queue( selected_account_context.recovery_queue, )); + let _ = state_store.apply_in_memory(AppStateCommand::replace_reminder_log( + selected_account_context.reminder_log, + )); let _ = state_store.apply_in_memory(AppStateCommand::replace_order_detail( selected_account_context.order_detail, )); @@ -1996,6 +2008,11 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::replace_orders_recovery_queue( context.recovery_queue.clone(), )); + let reminder_log_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_reminder_log( + context.reminder_log.clone(), + )); let order_detail_changed = self.state_store .apply_in_memory(AppStateCommand::replace_order_detail( @@ -2021,6 +2038,7 @@ impl DesktopAppRuntimeState { || orders_changed || orders_reminders_changed || recovery_queue_changed + || reminder_log_changed || order_detail_changed || pack_day_changed || editor_changed @@ -2126,6 +2144,62 @@ impl DesktopAppRuntimeState { self.refresh_selected_account_sync() } + fn acknowledge_reminder(&mut self, reminder_id: ReminderId) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let Some(selected_account) = self + .state_store + .identity_projection() + .selected_account + .as_ref() + else { + return Ok(false); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Ok(false); + }; + let account_id = selected_account.account.account_id.clone(); + let mut schedule = sqlite_store.load_reminder_schedule(account_id.as_str(), farm_id)?; + let Some(reminder) = schedule + .items + .iter_mut() + .find(|item| item.reminder_id == reminder_id) + else { + return Ok(false); + }; + if matches!( + reminder.delivery_state, + ReminderDeliveryState::Acknowledged | ReminderDeliveryState::Resolved + ) { + return Ok(false); + } + + reminder.delivery_state = ReminderDeliveryState::Acknowledged; + let reminder_log_entry = + build_reminder_log_entry(reminder, ReminderDeliveryState::Acknowledged); + sqlite_store.apply_reminder_schedule_update( + account_id.as_str(), + farm_id, + &schedule, + &[reminder_log_entry], + )?; + + let selected_account_context = load_selected_account_context_with_options( + 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(), + false, + )?; + + let _ = self.apply_selected_account_context(&selected_account_context); + + Ok(true) + } + fn attempt_sync(&mut self, trigger: SyncTrigger) -> Result<bool, AppSqliteError> { let Some(prepared) = self.prepare_sync_request(trigger)? else { return Ok(false); @@ -2796,6 +2870,26 @@ fn load_selected_account_context( selected_order_id: Option<OrderId>, pack_day_query: PackDayScreenQueryState, ) -> Result<DesktopSelectedAccountContext, AppSqliteError> { + load_selected_account_context_with_options( + sqlite_store, + identity_projection, + products_query, + orders_query, + selected_order_id, + pack_day_query, + true, + ) +} + +fn load_selected_account_context_with_options( + sqlite_store: &AppSqliteStore, + identity_projection: &AppIdentityProjection, + products_query: ProductsScreenQueryState, + orders_query: OrdersScreenQueryState, + selected_order_id: Option<OrderId>, + pack_day_query: PackDayScreenQueryState, + allow_auto_present: bool, +) -> Result<DesktopSelectedAccountContext, AppSqliteError> { let buyer_context = identity_projection.buyer_context(); let buyer_fulfillment_methods = BTreeSet::new(); let buyer_listings = sqlite_store.load_buyer_listings("", &buyer_fulfillment_methods)?; @@ -2828,6 +2922,7 @@ fn load_selected_account_context( orders_list: OrdersListProjection::default(), orders_reminders: ReminderFeedProjection::default(), recovery_queue: RecoveryQueueProjection::default(), + reminder_log: ReminderLogProjection::default(), ..DesktopSelectedAccountContext::default() }); }; @@ -2881,9 +2976,9 @@ fn load_selected_account_context( 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 { + let (orders_reminders, recovery_queue, reminder_log) = match today_farm_id { Some(farm_id) => { - let reminder_context = load_selected_account_reminder_context( + let reminder_context = load_selected_account_reminder_context_with_options( sqlite_store, selected_account.account.account_id.as_str(), farm_id, @@ -2891,6 +2986,7 @@ fn load_selected_account_context( &canonical_orders_list, &pack_day_projection, order_detail.as_ref(), + allow_auto_present, )?; today_projection.reminders = reminder_context.today_feed; if let Some(summary) = today_projection.summary.as_mut() { @@ -2905,11 +3001,13 @@ fn load_selected_account_context( ( reminder_context.orders_feed, reminder_context.recovery_queue, + reminder_context.reminder_log, ) } None => ( ReminderFeedProjection::default(), RecoveryQueueProjection::default(), + ReminderLogProjection::default(), ), }; @@ -2922,6 +3020,7 @@ fn load_selected_account_context( orders_list, orders_reminders, recovery_queue, + reminder_log, order_detail, pack_day_projection, }) @@ -2936,10 +3035,32 @@ fn load_selected_account_reminder_context( pack_day_projection: &PackDayProjection, selected_order_detail: Option<&OrderDetailProjection>, ) -> Result<DesktopSellerReminderContext, AppSqliteError> { + load_selected_account_reminder_context_with_options( + sqlite_store, + account_id, + farm_id, + today_projection, + canonical_orders_list, + pack_day_projection, + selected_order_detail, + true, + ) +} + +fn load_selected_account_reminder_context_with_options( + 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>, + allow_auto_present: bool, +) -> 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( + let mut schedule = derive_selected_account_reminder_schedule( farm_id, today_projection, canonical_orders_list, @@ -2948,9 +3069,22 @@ fn load_selected_account_reminder_context( &sync_truth, &existing_schedule, ); - if schedule != existing_schedule { - sqlite_store.replace_reminder_schedule(account_id, farm_id, &schedule)?; + let mut reminder_log_entries = + reconcile_resolved_reminder_log_entries(&existing_schedule, &schedule); + promote_desktop_reminder_presentation( + &mut schedule, + &mut reminder_log_entries, + allow_auto_present, + ); + if schedule != existing_schedule || !reminder_log_entries.is_empty() { + sqlite_store.apply_reminder_schedule_update( + account_id, + farm_id, + &schedule, + &reminder_log_entries, + )?; } + let reminder_log = sqlite_store.load_reminder_log(account_id, farm_id, 8)?; let selected_order_recovery = selected_order_detail.and_then(|detail| { recovery_queue @@ -2986,6 +3120,7 @@ fn load_selected_account_reminder_context( selected_order_recovery, due_soon_count, recovery_actions_open, + reminder_log, }) } @@ -3322,6 +3457,100 @@ fn filter_reminder_surface( } } +fn reconcile_resolved_reminder_log_entries( + existing_schedule: &ReminderFeedProjection, + schedule: &ReminderFeedProjection, +) -> Vec<ReminderLogEntryProjection> { + existing_schedule + .items + .iter() + .filter(|existing| { + existing.delivery_state != ReminderDeliveryState::Scheduled + && existing.delivery_state != ReminderDeliveryState::Resolved + && !schedule + .items + .iter() + .any(|current| current.reminder_id == existing.reminder_id) + }) + .map(|reminder| build_reminder_log_entry(reminder, ReminderDeliveryState::Resolved)) + .collect() +} + +fn promote_desktop_reminder_presentation( + schedule: &mut ReminderFeedProjection, + reminder_log_entries: &mut Vec<ReminderLogEntryProjection>, + allow_auto_present: bool, +) { + if !allow_auto_present || schedule.items.iter().any(is_desktop_presented_reminder) { + return; + } + + let Some(index) = schedule + .items + .iter() + .enumerate() + .filter(|(_, reminder)| { + reminder.delivery_state == ReminderDeliveryState::Scheduled + && is_desktop_presentation_candidate(reminder) + }) + .min_by(|(_, left), (_, right)| desktop_reminder_sort(left, right)) + .map(|(index, _)| index) + else { + return; + }; + + schedule.items[index].delivery_state = ReminderDeliveryState::Presented; + reminder_log_entries.push(build_reminder_log_entry( + &schedule.items[index], + ReminderDeliveryState::Presented, + )); +} + +fn is_desktop_presented_reminder(reminder: &ReminderDeadlineProjection) -> bool { + reminder.delivery_state == ReminderDeliveryState::Presented + && is_desktop_presentation_candidate(reminder) +} + +fn is_desktop_presentation_candidate(reminder: &ReminderDeadlineProjection) -> bool { + matches!( + reminder.urgency, + ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking + ) +} + +fn desktop_reminder_sort( + left: &ReminderDeadlineProjection, + right: &ReminderDeadlineProjection, +) -> std::cmp::Ordering { + desktop_reminder_priority(left.urgency) + .cmp(&desktop_reminder_priority(right.urgency)) + .then_with(|| left.deadline_at.cmp(&right.deadline_at)) + .then_with(|| left.reminder_id.cmp(&right.reminder_id)) +} + +fn desktop_reminder_priority(urgency: ReminderUrgency) -> u8 { + match urgency { + ReminderUrgency::Blocking => 0, + ReminderUrgency::Overdue => 1, + ReminderUrgency::DueSoon => 2, + ReminderUrgency::Upcoming => 3, + } +} + +fn build_reminder_log_entry( + reminder: &ReminderDeadlineProjection, + delivery_state: ReminderDeliveryState, +) -> ReminderLogEntryProjection { + ReminderLogEntryProjection { + reminder_id: reminder.reminder_id, + kind: reminder.kind, + title: reminder.title.clone(), + recorded_at: current_utc_timestamp(), + delivery_state, + detail: (!reminder.detail.trim().is_empty()).then_some(reminder.detail.clone()), + } +} + fn load_selected_account_sync_context( sqlite_store: &AppSqliteStore, identity_projection: &AppIdentityProjection, @@ -3731,9 +3960,9 @@ mod tests { FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, PersonalSection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductStatus, ProductsFilter, - ProductsSort, RecoveryKind, RecoveryRecordId, ReminderKind, SelectedSurfaceProjection, - SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, TodaySummary, + ProductsSort, RecoveryKind, RecoveryRecordId, ReminderDeliveryState, ReminderKind, + SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -5959,6 +6188,147 @@ mod tests { } #[test] + fn runtime_refresh_promotes_blocking_sync_reminders_into_presented_log_entries() { + let runtime = memory_runtime(); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .record_sync_conflict( + account_id.as_str(), + &SyncConflict { + aggregate: SyncAggregateRef::Farm(farm_id), + kind: SyncConflictKind::RevisionMismatch, + severity: SyncConflictSeverity::Blocking, + resolution: SyncConflictResolutionStatus::Unresolved, + local_payload_json: "{\"farm\":\"local\"}".to_owned(), + remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), + detected_at: "2026-04-20T20:10:00Z".to_owned(), + resolved_at: None, + }, + ) + .expect("blocking conflict should save"); + + assert!( + runtime + .lock_state_mut() + .refresh_selected_account_sync() + .expect("sync status should refresh") + ); + + let summary = runtime.summary(); + let reminder = summary + .orders_projection + .reminders + .items + .iter() + .find(|item| item.kind == ReminderKind::SyncImpact) + .expect("sync reminder"); + + assert_eq!(reminder.delivery_state, ReminderDeliveryState::Presented); + assert!(summary.reminder_log.entries.iter().any(|entry| { + entry.reminder_id == reminder.reminder_id + && entry.delivery_state == ReminderDeliveryState::Presented + })); + } + + #[test] + fn runtime_resolving_an_acknowledged_reminder_records_the_resolved_log_entry() { + let runtime = memory_runtime(); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + + let conflict_id = runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .record_sync_conflict( + account_id.as_str(), + &SyncConflict { + aggregate: SyncAggregateRef::Farm(farm_id), + kind: SyncConflictKind::RevisionMismatch, + severity: SyncConflictSeverity::Blocking, + resolution: SyncConflictResolutionStatus::Unresolved, + local_payload_json: "{\"farm\":\"local\"}".to_owned(), + remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), + detected_at: "2026-04-20T20:15:00Z".to_owned(), + resolved_at: None, + }, + ) + .expect("blocking conflict should save"); + assert!( + runtime + .lock_state_mut() + .refresh_selected_account_sync() + .expect("sync status should refresh") + ); + + let reminder_id = runtime + .summary() + .orders_projection + .reminders + .items + .iter() + .find(|item| item.kind == ReminderKind::SyncImpact) + .expect("sync reminder") + .reminder_id; + assert!( + runtime + .acknowledge_reminder(reminder_id) + .expect("reminder should acknowledge") + ); + + let acknowledged_summary = runtime.summary(); + assert!( + acknowledged_summary + .orders_projection + .reminders + .items + .iter() + .any(|item| { + item.reminder_id == reminder_id + && item.delivery_state == ReminderDeliveryState::Acknowledged + }) + ); + assert!( + acknowledged_summary + .reminder_log + .entries + .iter() + .any(|entry| { + entry.reminder_id == reminder_id + && entry.delivery_state == ReminderDeliveryState::Acknowledged + }) + ); + + assert!( + runtime + .resolve_sync_conflict( + conflict_id.as_str(), + SyncConflictResolutionStatus::AcceptedLocal, + ) + .expect("conflict resolution should succeed") + ); + + let resolved_summary = runtime.summary(); + assert!( + resolved_summary + .orders_projection + .reminders + .items + .iter() + .all(|item| { item.reminder_id != reminder_id }) + ); + assert!(resolved_summary.reminder_log.entries.iter().any(|entry| { + entry.reminder_id == reminder_id + && entry.delivery_state == ReminderDeliveryState::Resolved + })); + } + + #[test] fn runtime_threads_recovery_queue_into_today_counts_and_order_detail() { let runtime = memory_runtime(); let (_, farm_id) = provision_ready_farmer_account(&runtime); diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -73,6 +73,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to mark order packed", "failed to open existing product editor", "failed to open new product editor", + "failed to acknowledge reminder", "failed to open order detail", "failed to route into pack day view", "failed to route into orders view", @@ -114,6 +115,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "personal-search-delivery", "personal-search-pickup", "personal-search-shipping", + "presented reminder", "buyer-search-scroll", "home-today-open-pack-day", "home-today-order-open", @@ -154,6 +156,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "orders.route_failed", "preview", "products", + "reminder-banner-action", + "reminder-banner-dismiss", + "reminders", + "reminders.ack_failed", "products-filter-all", "products-filter-archived", "products-filter-drafts", @@ -367,6 +373,8 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersDetailWindowLabel", "AppTextKey::OrdersDetailPickupLabel", "AppTextKey::OrdersRemindersTitle", + "AppTextKey::OrdersReminderLogTitle", + "AppTextKey::OrdersReminderLogEmptyBody", "AppTextKey::PackDayTitle", "AppTextKey::PackDayRemindersTitle", "AppTextKey::PackDayWindowSummaryTitle", @@ -381,6 +389,12 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::ReminderUrgencyDueSoon", "AppTextKey::ReminderUrgencyOverdue", "AppTextKey::ReminderUrgencyBlocking", + "AppTextKey::ReminderPresentationTitle", + "AppTextKey::ReminderPresentationDismissAction", + "AppTextKey::ReminderDeliveryStateScheduled", + "AppTextKey::ReminderDeliveryStatePresented", + "AppTextKey::ReminderDeliveryStateAcknowledged", + "AppTextKey::ReminderDeliveryStateResolved", "AppTextKey::ProductsTitle", "AppTextKey::ProductsFiltersTitle", "AppTextKey::ProductsSearchPlaceholder", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -23,7 +23,8 @@ use radroots_app_models::{ PackDayRosterRow, PersonalEntryState, PersonalSection, PickupLocationId, PickupLocationRecord, ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, - ReminderDeadlineProjection, ReminderUrgency, ShellSection, TodayAgendaProjection, + ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderLogEntryProjection, + ReminderLogProjection, ReminderSurface, ReminderUrgency, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ @@ -1473,6 +1474,87 @@ impl HomeView { } } + fn dismiss_presented_reminder(&mut self, reminder_id: ReminderId, cx: &mut Context<Self>) { + match self.runtime.acknowledge_reminder(reminder_id) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "reminders", + event = "reminders.ack_failed", + error = %runtime_error, + reminder_id = %reminder_id, + "failed to acknowledge reminder" + ); + } + } + } + + fn open_presented_order_reminder( + &mut self, + reminder_id: ReminderId, + order_id: OrderId, + cx: &mut Context<Self>, + ) { + match self.runtime.open_order_detail(order_id) { + Ok(true) | Ok(false) => { + self.products_stock_editor = None; + self.product_editor_form = None; + self.dismiss_presented_reminder(reminder_id, cx); + } + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.detail_open_failed", + error = %runtime_error, + order_id = %order_id, + "failed to open order detail" + ); + } + } + } + + fn open_presented_pack_day_reminder( + &mut self, + reminder_id: ReminderId, + fulfillment_window_id: FulfillmentWindowId, + cx: &mut Context<Self>, + ) { + match self.runtime.open_pack_day(Some(fulfillment_window_id)) { + Ok(true) | Ok(false) => { + self.products_stock_editor = None; + self.product_editor_form = None; + self.dismiss_presented_reminder(reminder_id, cx); + } + Err(runtime_error) => { + error!( + target: "pack_day", + event = "pack_day.route_failed", + error = %runtime_error, + "failed to route into pack day view" + ); + } + } + } + + fn open_presented_orders_reminder(&mut self, reminder_id: ReminderId, cx: &mut Context<Self>) { + match self.runtime.open_orders() { + Ok(true) | Ok(false) => { + self.products_stock_editor = None; + self.product_editor_form = None; + self.dismiss_presented_reminder(reminder_id, cx); + } + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.route_failed", + error = %runtime_error, + "failed to route into orders view" + ); + } + } + } + fn mark_order_packed(&mut self, order_id: OrderId, cx: &mut Context<Self>) { match self.runtime.mark_order_packed(order_id) { Ok(true) => cx.notify(), @@ -2537,6 +2619,20 @@ impl HomeView { cx.listener(|this, _, _, cx| this.open_account_entry(cx)), cx, )) + .when_some(presented_farmer_reminder(runtime), |this, reminder| { + this.child( + div() + .w_full() + .px(px(APP_UI_THEME.shells.home_window_padding_px)) + .child( + div() + .w_full() + .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) + .mx_auto() + .child(self.render_presented_reminder_banner(reminder, cx)), + ), + ) + }) .child( app_scroll_panel( home_content_scroll_id(selected_farmer_section), @@ -2751,6 +2847,7 @@ impl HomeView { cx, )) }) + .child(self.render_orders_reminder_log_card(&runtime.reminder_log)) .child(if projection.list.is_empty() { orders_empty_state_card(projection.query.filter).into_any_element() } else { @@ -2778,6 +2875,153 @@ impl HomeView { .into_any_element() } + fn render_presented_reminder_banner( + &mut self, + reminder: &ReminderDeadlineProjection, + cx: &mut Context<Self>, + ) -> AnyElement { + let primary_action = self.render_presented_reminder_primary_action(reminder, cx); + + home_card( + app_shared_text(AppTextKey::ReminderPresentationTitle), + app_stack_v(APP_UI_THEME.foundation.spacing.medium_px) + .w_full() + .child( + div() + .w_full() + .min_w_0() + .flex() + .items_start() + .justify_between() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .child(status_indicator(reminder_urgency_color(reminder.urgency))) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(APP_UI_THEME + .foundation + .typography + .body_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(reminder.title.clone()), + ), + ) + .child( + app_cluster(APP_UI_THEME.foundation.spacing.tight_px) + .child(reminder_urgency_badge(reminder.urgency)) + .child(reminder_delivery_state_badge(reminder.delivery_state)), + ), + ) + .when(!reminder.detail.trim().is_empty(), |this| { + this.child(home_body_text(reminder.detail.clone())) + }) + .child( + div() + .w_full() + .min_w_0() + .flex() + .items_center() + .justify_between() + .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) + .child( + div() + .min_w_0() + .text_size(px(APP_UI_THEME + .foundation + .typography + .utility_title_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(reminder_deadline_text(reminder)), + ) + .child( + div() + .flex() + .items_center() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .when_some(primary_action, |this, action| this.child(action)) + .child(text_button( + "reminder-banner-dismiss", + app_shared_text(AppTextKey::ReminderPresentationDismissAction), + cx.listener({ + let reminder_id = reminder.reminder_id; + move |this, _, _, cx| { + this.dismiss_presented_reminder(reminder_id, cx) + } + }), + cx, + )), + ), + ), + ) + .into_any_element() + } + + fn render_presented_reminder_primary_action( + &mut self, + reminder: &ReminderDeadlineProjection, + cx: &mut Context<Self>, + ) -> Option<AnyElement> { + let label = reminder.action_label.clone()?; + + match reminder_action_target(reminder) { + Some(ReminderActionTarget::OrderDetail(order_id)) => Some( + action_button_primary( + "reminder-banner-action", + SharedString::from(label), + cx.listener({ + let reminder_id = reminder.reminder_id; + move |this, _, _, cx| { + this.open_presented_order_reminder(reminder_id, order_id, cx) + } + }), + cx, + ) + .into_any_element(), + ), + Some(ReminderActionTarget::PackDay(fulfillment_window_id)) => Some( + action_button_primary( + "reminder-banner-action", + SharedString::from(label), + cx.listener({ + let reminder_id = reminder.reminder_id; + move |this, _, _, cx| { + this.open_presented_pack_day_reminder( + reminder_id, + fulfillment_window_id, + cx, + ) + } + }), + cx, + ) + .into_any_element(), + ), + None if reminder.surface == ReminderSurface::Orders => Some( + action_button_primary( + "reminder-banner-action", + SharedString::from(label), + cx.listener({ + let reminder_id = reminder.reminder_id; + move |this, _, _, cx| this.open_presented_orders_reminder(reminder_id, cx) + }), + cx, + ) + .into_any_element(), + ), + None => None, + } + } + fn render_pack_day_content( &mut self, runtime: &DesktopAppRuntimeSummary, @@ -2963,6 +3207,71 @@ impl HomeView { .into_any_element() } + fn render_orders_reminder_log_card(&self, reminder_log: &ReminderLogProjection) -> AnyElement { + let body = if reminder_log.entries.is_empty() { + home_body_text(app_shared_text(AppTextKey::OrdersReminderLogEmptyBody)) + .into_any_element() + } else { + let mut rows = Vec::with_capacity(reminder_log.entries.len().saturating_mul(2)); + for (index, entry) in reminder_log.entries.iter().enumerate() { + rows.push(self.render_orders_reminder_log_row(entry)); + if index + 1 < reminder_log.entries.len() { + rows.push(section_divider().into_any_element()); + } + } + + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) + .children(rows) + .into_any_element() + }; + + home_card(app_shared_text(AppTextKey::OrdersReminderLogTitle), body).into_any_element() + } + + fn render_orders_reminder_log_row(&self, entry: &ReminderLogEntryProjection) -> AnyElement { + app_stack_v(APP_UI_THEME.foundation.spacing.tight_px) + .w_full() + .child( + div() + .w_full() + .min_w_0() + .flex() + .items_start() + .justify_between() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(entry.title.clone()), + ) + .child(reminder_delivery_state_badge(entry.delivery_state)), + ) + .when_some( + entry + .detail + .as_ref() + .map(|detail| detail.trim()) + .filter(|detail| !detail.is_empty()), + |this, detail| this.child(home_body_text(detail.to_owned())), + ) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(entry.recorded_at.clone()), + ) + .into_any_element() + } + fn render_reminder_feed_row( &mut self, scope: &'static str, @@ -8656,6 +8965,68 @@ fn reminder_urgency_badge(urgency: ReminderUrgency) -> AnyElement { .into_any_element() } +fn reminder_delivery_state_key(delivery_state: ReminderDeliveryState) -> AppTextKey { + match delivery_state { + ReminderDeliveryState::Scheduled => AppTextKey::ReminderDeliveryStateScheduled, + ReminderDeliveryState::Presented => AppTextKey::ReminderDeliveryStatePresented, + ReminderDeliveryState::Acknowledged => AppTextKey::ReminderDeliveryStateAcknowledged, + ReminderDeliveryState::Resolved => AppTextKey::ReminderDeliveryStateResolved, + } +} + +fn reminder_delivery_state_color(delivery_state: ReminderDeliveryState) -> u32 { + match delivery_state { + ReminderDeliveryState::Scheduled => APP_UI_THEME.components.app_status_indicator.offline, + ReminderDeliveryState::Presented => APP_UI_THEME.foundation.text.accent, + ReminderDeliveryState::Acknowledged => APP_UI_THEME.foundation.text.secondary, + ReminderDeliveryState::Resolved => APP_UI_THEME.components.app_status_indicator.online, + } +} + +fn reminder_delivery_state_badge(delivery_state: ReminderDeliveryState) -> AnyElement { + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(reminder_delivery_state_color(delivery_state))) + .child(app_shared_text(reminder_delivery_state_key(delivery_state))) + .into_any_element() +} + +fn presented_farmer_reminder( + runtime: &DesktopAppRuntimeSummary, +) -> Option<&ReminderDeadlineProjection> { + runtime + .today_projection + .reminders + .items + .iter() + .chain(runtime.orders_projection.reminders.items.iter()) + .chain( + runtime + .pack_day_projection + .projection + .reminders + .items + .iter(), + ) + .filter(|reminder| reminder.delivery_state == ReminderDeliveryState::Presented) + .min_by(|left, right| { + reminder_urgency_priority(left.urgency) + .cmp(&reminder_urgency_priority(right.urgency)) + .then_with(|| left.deadline_at.cmp(&right.deadline_at)) + .then_with(|| left.reminder_id.cmp(&right.reminder_id)) + }) +} + +fn reminder_urgency_priority(urgency: ReminderUrgency) -> u8 { + match urgency { + ReminderUrgency::Blocking => 0, + ReminderUrgency::Overdue => 1, + ReminderUrgency::DueSoon => 2, + ReminderUrgency::Upcoming => 3, + } +} + fn reminder_deadline_text(reminder: &ReminderDeadlineProjection) -> String { format!( "{}: {}", @@ -10274,8 +10645,9 @@ mod tests { farmer_pack_day_available, home_content_scroll_id, home_saved_farm, home_sidebar_navigation_sections, home_stage, home_window_launch_size_px, home_window_minimum_size_px, parse_optional_product_editor_stock_input, - parse_product_editor_price_input, product_display_title, reminder_action_target, - reminder_deadline_text, reminder_urgency_color, reminder_urgency_key, startup_home_surface, + parse_product_editor_price_input, presented_farmer_reminder, product_display_title, + reminder_action_target, reminder_deadline_text, reminder_delivery_state_key, + reminder_urgency_color, reminder_urgency_key, startup_home_surface, startup_signer_preview_summary, startup_signer_preview_summary_for_connect_state, startup_signer_source_input_is_editable, startup_signer_status_spec, startup_signer_transport_failure_requires_notice, @@ -10872,6 +11244,71 @@ mod tests { } #[test] + fn reminder_delivery_state_key_matches_the_local_presentation_contract() { + assert_eq!( + reminder_delivery_state_key(ReminderDeliveryState::Scheduled), + AppTextKey::ReminderDeliveryStateScheduled + ); + assert_eq!( + reminder_delivery_state_key(ReminderDeliveryState::Presented), + AppTextKey::ReminderDeliveryStatePresented + ); + assert_eq!( + reminder_delivery_state_key(ReminderDeliveryState::Acknowledged), + AppTextKey::ReminderDeliveryStateAcknowledged + ); + assert_eq!( + reminder_delivery_state_key(ReminderDeliveryState::Resolved), + AppTextKey::ReminderDeliveryStateResolved + ); + } + + #[test] + fn presented_farmer_reminder_prefers_the_highest_priority_presented_item() { + let mut runtime = summary( + HomeRoute::Today, + TodayAgendaProjection::default(), + FarmSetupProjection::default(), + ); + let due_soon = fixture_reminder( + None, + Some(FulfillmentWindowId::new()), + ReminderKind::FulfillmentWindow, + ReminderUrgency::DueSoon, + ); + let blocking = fixture_reminder( + None, + None, + ReminderKind::SyncImpact, + ReminderUrgency::Blocking, + ); + + runtime + .today_projection + .reminders + .items + .push(ReminderDeadlineProjection { + delivery_state: ReminderDeliveryState::Presented, + ..due_soon + }); + runtime + .orders_projection + .reminders + .items + .push(ReminderDeadlineProjection { + delivery_state: ReminderDeliveryState::Presented, + ..blocking.clone() + }); + + assert_eq!( + presented_farmer_reminder(&runtime) + .expect("presented reminder") + .reminder_id, + blocking.reminder_id + ); + } + + #[test] fn about_status_rows_disable_sync_without_a_selected_account() { let rows = about_status_rows(&summary( HomeRoute::SetupRequired, @@ -11069,6 +11506,7 @@ mod tests { products_projection: Default::default(), orders_projection: Default::default(), pack_day_projection: Default::default(), + reminder_log: Default::default(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), sync_status: crate::runtime::DesktopAppSyncStatusSummary::default(), startup_issue: None, diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -182,6 +182,8 @@ define_app_text_keys! { OrdersDetailWindowLabel => "orders.detail.window.label", OrdersDetailPickupLabel => "orders.detail.pickup.label", OrdersRemindersTitle => "orders.reminders.title", + OrdersReminderLogTitle => "orders.reminder_log.title", + OrdersReminderLogEmptyBody => "orders.reminder_log.empty.body", PackDayTitle => "pack_day.title", PackDayRemindersTitle => "pack_day.reminders.title", PackDayWindowSummaryTitle => "pack_day.window_summary.title", @@ -196,6 +198,12 @@ define_app_text_keys! { ReminderUrgencyDueSoon => "reminder.urgency.due_soon", ReminderUrgencyOverdue => "reminder.urgency.overdue", ReminderUrgencyBlocking => "reminder.urgency.blocking", + ReminderPresentationTitle => "reminder.presentation.title", + ReminderPresentationDismissAction => "reminder.presentation.dismiss_action", + ReminderDeliveryStateScheduled => "reminder.delivery_state.scheduled", + ReminderDeliveryStatePresented => "reminder.delivery_state.presented", + ReminderDeliveryStateAcknowledged => "reminder.delivery_state.acknowledged", + ReminderDeliveryStateResolved => "reminder.delivery_state.resolved", ProductsTitle => "products.title", ProductsFiltersTitle => "products.filters.title", ProductsSearchPlaceholder => "products.search.placeholder", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -172,12 +172,32 @@ mod tests { assert_eq!(app_text(AppTextKey::HomeTodayRemindersTitle), "Coming up"); assert_eq!(app_text(AppTextKey::OrdersRemindersTitle), "Reminders"); assert_eq!( + app_text(AppTextKey::OrdersReminderLogTitle), + "Reminder activity" + ); + assert_eq!( app_text(AppTextKey::PackDayRemindersTitle), "Before this window" ); assert_eq!(app_text(AppTextKey::ReminderDeadlineLabel), "Due"); assert_eq!(app_text(AppTextKey::ReminderUrgencyDueSoon), "Due soon"); assert_eq!(app_text(AppTextKey::ReminderUrgencyBlocking), "Blocking"); + assert_eq!( + app_text(AppTextKey::ReminderPresentationTitle), + "Needs attention now" + ); + assert_eq!( + app_text(AppTextKey::ReminderPresentationDismissAction), + "Dismiss" + ); + assert_eq!( + app_text(AppTextKey::ReminderDeliveryStatePresented), + "Presented" + ); + assert_eq!( + app_text(AppTextKey::ReminderDeliveryStateResolved), + "Resolved" + ); } #[test] diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -280,6 +280,21 @@ impl AppSqliteStore { .replace_reminder_schedule(account_id, farm_id, projection) } + pub fn apply_reminder_schedule_update( + &self, + account_id: &str, + farm_id: FarmId, + projection: &ReminderFeedProjection, + log_entries: &[ReminderLogEntryProjection], + ) -> Result<(), AppSqliteError> { + self.reminders_repository().apply_reminder_schedule_update( + account_id, + farm_id, + projection, + log_entries, + ) + } + pub fn record_reminder_log_entry( &self, account_id: &str, @@ -694,11 +709,31 @@ mod tests { assert!(column_exists(connection, "orders", "buyer_email")); assert!(column_exists(connection, "orders", "buyer_phone")); assert!(column_exists(connection, "orders", "buyer_order_note")); - assert!(column_exists(connection, "reminder_schedules", "account_id")); - assert!(column_exists(connection, "reminder_schedules", "delivery_state")); - assert!(column_exists(connection, "reminder_log_entries", "recorded_at")); - assert!(column_exists(connection, "order_recovery_records", "recovery_kind")); - assert!(column_exists(connection, "order_recovery_records", "recovery_state")); + assert!(column_exists( + connection, + "reminder_schedules", + "account_id" + )); + assert!(column_exists( + connection, + "reminder_schedules", + "delivery_state" + )); + assert!(column_exists( + connection, + "reminder_log_entries", + "recorded_at" + )); + assert!(column_exists( + connection, + "order_recovery_records", + "recovery_kind" + )); + assert!(column_exists( + connection, + "order_recovery_records", + "recovery_state" + )); assert_eq!(row_count(connection, "sync_checkpoints"), 0); drop(store); diff --git a/crates/shared/sqlite/src/reminders.rs b/crates/shared/sqlite/src/reminders.rs @@ -1,8 +1,7 @@ use radroots_app_models::{ - FarmId, OrderId, OrderRecoveryProjection, RecoveryKind, RecoveryQueueProjection, - RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, - ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, - ReminderUrgency, + FarmId, OrderId, OrderRecoveryProjection, RecoveryKind, RecoveryQueueProjection, RecoveryState, + ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderKind, + ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, }; use rusqlite::{Connection, OptionalExtension, params}; use std::str::FromStr; @@ -116,13 +115,23 @@ impl<'a> AppRemindersRepository<'a> { farm_id: FarmId, projection: &ReminderFeedProjection, ) -> Result<(), AppSqliteError> { - let transaction = self - .connection - .unchecked_transaction() - .map_err(|source| AppSqliteError::Query { - operation: "begin reminder schedule replacement", - source, - })?; + self.apply_reminder_schedule_update(account_id, farm_id, projection, &[]) + } + + pub fn apply_reminder_schedule_update( + &self, + account_id: &str, + farm_id: FarmId, + projection: &ReminderFeedProjection, + log_entries: &[ReminderLogEntryProjection], + ) -> Result<(), AppSqliteError> { + let transaction = + self.connection + .unchecked_transaction() + .map_err(|source| AppSqliteError::Query { + operation: "begin reminder schedule replacement", + source, + })?; transaction .execute( @@ -184,10 +193,46 @@ impl<'a> AppRemindersRepository<'a> { } } - transaction.commit().map_err(|source| AppSqliteError::Query { - operation: "commit reminder schedule replacement", - source, - })?; + for entry in log_entries { + let log_entry_id = Uuid::now_v7().to_string(); + + transaction + .execute( + "INSERT INTO reminder_log_entries ( + log_entry_id, + account_id, + farm_id, + reminder_id, + reminder_kind, + title, + recorded_at, + delivery_state, + detail + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + log_entry_id, + account_id, + farm_id.to_string(), + entry.reminder_id.to_string(), + entry.kind.storage_key(), + entry.title, + entry.recorded_at, + entry.delivery_state.storage_key(), + entry.detail, + ], + ) + .map_err(|source| AppSqliteError::Query { + operation: "record reminder log entry", + source, + })?; + } + + transaction + .commit() + .map_err(|source| AppSqliteError::Query { + operation: "commit reminder schedule replacement", + source, + })?; Ok(()) } @@ -259,16 +304,19 @@ impl<'a> AppRemindersRepository<'a> { source, })?; let rows = statement - .query_map(params![account_id, farm_id.to_string(), limit as i64], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, Option<String>>(5)?, - )) - }) + .query_map( + params![account_id, farm_id.to_string(), limit as i64], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option<String>>(5)?, + )) + }, + ) .map_err(|source| AppSqliteError::Query { operation: "query reminder log", source, @@ -276,8 +324,8 @@ impl<'a> AppRemindersRepository<'a> { let entries = rows .map(|row| { - let (reminder_id, reminder_kind, title, recorded_at, delivery_state, detail) = - row.map_err(|source| AppSqliteError::Query { + let (reminder_id, reminder_kind, title, recorded_at, delivery_state, detail) = row + .map_err(|source| AppSqliteError::Query { operation: "read reminder log row", source, })?; @@ -572,9 +620,7 @@ fn parse_optional_typed_id<T>( where T: FromStr<Err = uuid::Error>, { - value - .map(|value| parse_typed_id(field, value)) - .transpose() + value.map(|value| parse_typed_id(field, value)).transpose() } #[cfg(test)] @@ -619,7 +665,11 @@ mod tests { ) .expect("schedule should save"); repository - .replace_reminder_schedule("acct_other", other_farm_id, &ReminderFeedProjection::default()) + .replace_reminder_schedule( + "acct_other", + other_farm_id, + &ReminderFeedProjection::default(), + ) .expect("other schedule should save"); let loaded = repository diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -554,6 +554,7 @@ pub enum AppStateCommand { ReplaceOrdersList(OrdersListProjection), ReplaceOrdersReminders(ReminderFeedProjection), ReplaceOrdersRecoveryQueue(RecoveryQueueProjection), + ReplaceReminderLog(ReminderLogProjection), ReplaceOrderDetail(Option<OrderDetailProjection>), SetPackDayFulfillmentWindow(Option<FulfillmentWindowId>), ReplacePackDayProjection(PackDayProjection), @@ -661,6 +662,10 @@ impl AppStateCommand { Self::ReplaceOrdersRecoveryQueue(projection) } + pub fn replace_reminder_log(projection: ReminderLogProjection) -> Self { + Self::ReplaceReminderLog(projection) + } + pub fn replace_order_detail(projection: Option<OrderDetailProjection>) -> Self { Self::ReplaceOrderDetail(projection) } @@ -832,6 +837,10 @@ impl<R: AppStateRepository> AppStateStore<R> { &self.projection.orders } + pub fn reminder_log_projection(&self) -> &ReminderLogProjection { + &self.projection.reminder_log + } + pub fn pack_day_projection(&self) -> &PackDayScreenProjection { &self.projection.pack_day } @@ -1101,6 +1110,9 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateCommand::ReplaceOrdersRecoveryQueue(recovery_queue_projection) => { projection.orders.recovery_queue = recovery_queue_projection; } + AppStateCommand::ReplaceReminderLog(reminder_log_projection) => { + projection.reminder_log = reminder_log_projection; + } AppStateCommand::ReplaceOrderDetail(order_detail_projection) => { projection.orders.replace_detail(order_detail_projection); } @@ -1157,6 +1169,8 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateMutation::ProductsChanged } else if projection.orders != before.orders { AppStateMutation::OrdersChanged + } else if projection.reminder_log != before.reminder_log { + AppStateMutation::OrdersChanged } else if projection.pack_day != before.pack_day { AppStateMutation::PackDayChanged } else { @@ -1455,7 +1469,8 @@ mod tests { OrdersListSummary, OrdersScreenQueryState, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, PersonalEntryState, PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, - ProductsListProjection, ProductsSort, ReminderFeedProjection, SelectedAccountProjection, + ProductsListProjection, ProductsSort, ReminderDeliveryState, ReminderFeedProjection, + ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; @@ -1692,6 +1707,16 @@ mod tests { last_updated_at: "2026-04-18T19:00:00Z".to_owned(), }], }; + let reminder_log = ReminderLogProjection { + entries: vec![ReminderLogEntryProjection { + reminder_id: orders_reminders.items[0].reminder_id, + kind: ReminderKind::OrderAction, + title: "review order".to_owned(), + recorded_at: "2026-04-18T14:30:00Z".to_owned(), + delivery_state: ReminderDeliveryState::Presented, + detail: Some("Casey still needs confirmation.".to_owned()), + }], + }; let pack_day = PackDayProjection { fulfillment_window: Some(radroots_app_models::FulfillmentWindowSummary { fulfillment_window_id, @@ -1751,6 +1776,10 @@ mod tests { Ok(true) ); assert_eq!( + store.apply(AppStateCommand::replace_reminder_log(reminder_log.clone())), + Ok(true) + ); + assert_eq!( store.apply(AppStateCommand::replace_order_detail(Some( order_detail.clone() ))), @@ -1778,6 +1807,7 @@ 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().reminder_log, reminder_log); assert_eq!(store.projection().orders.detail, Some(order_detail)); assert_eq!( store.projection().pack_day.query, diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -162,6 +162,8 @@ "orders.detail.window.label": "Fulfillment window", "orders.detail.pickup.label": "Pickup location", "orders.reminders.title": "Reminders", + "orders.reminder_log.title": "Reminder activity", + "orders.reminder_log.empty.body": "Recent reminder activity appears here after something needs attention.", "pack_day.title": "Pack day", "pack_day.reminders.title": "Before this window", "pack_day.window_summary.title": "Window summary", @@ -175,6 +177,12 @@ "reminder.urgency.due_soon": "Due soon", "reminder.urgency.overdue": "Overdue", "reminder.urgency.blocking": "Blocking", + "reminder.presentation.title": "Needs attention now", + "reminder.presentation.dismiss_action": "Dismiss", + "reminder.delivery_state.scheduled": "Scheduled", + "reminder.delivery_state.presented": "Presented", + "reminder.delivery_state.acknowledged": "Acknowledged", + "reminder.delivery_state.resolved": "Resolved", "products.title": "Products", "products.filters.title": "View", "products.search.placeholder": "Search products",