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:
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",