app

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

commit 812700f1bd7bf64ab44e93df6febd13bf18caea6
parent 6d1fcaaa840a7c4d4d1d129992a18cbf2cc7773e
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 23:53:39 +0000

ui: land seller reminder surfaces

Diffstat:
Mcrates/launchers/desktop/src/source_guards.rs | 11+++++++++++
Mcrates/launchers/desktop/src/window.rs | 430+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/shared/i18n/src/keys.rs | 8++++++++
Mcrates/shared/i18n/src/lib.rs | 13+++++++++++++
Mi18n/locales/en/messages.json | 8++++++++
5 files changed, 453 insertions(+), 17 deletions(-)

diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -124,13 +124,16 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "home-today-open-products-low-stock", "home-products-scroll", "home-today-scroll", + "today-reminder-chip", "https://auth.example/challenge", "identity", "none", "npub1", "guest", "orders", + "orders-reminders", "pack_day", + "pack-day-reminders", "pack_day.route_failed", "orders-detail-mark-completed", "orders-detail-mark-packed", @@ -363,13 +366,21 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersDetailStatusLabel", "AppTextKey::OrdersDetailWindowLabel", "AppTextKey::OrdersDetailPickupLabel", + "AppTextKey::OrdersRemindersTitle", "AppTextKey::PackDayTitle", + "AppTextKey::PackDayRemindersTitle", "AppTextKey::PackDayWindowSummaryTitle", "AppTextKey::PackDayTotalsTitle", "AppTextKey::PackDayPackListTitle", "AppTextKey::PackDayPickupRosterTitle", "AppTextKey::PackDayEmptyTitle", "AppTextKey::PackDayEmptyBody", + "AppTextKey::HomeTodayRemindersTitle", + "AppTextKey::ReminderDeadlineLabel", + "AppTextKey::ReminderUrgencyUpcoming", + "AppTextKey::ReminderUrgencyDueSoon", + "AppTextKey::ReminderUrgencyOverdue", + "AppTextKey::ReminderUrgencyBlocking", "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, - ShellSection, TodayAgendaProjection, TodaySetupTaskKind, + ReminderDeadlineProjection, ReminderUrgency, ShellSection, TodayAgendaProjection, + TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -1897,6 +1898,10 @@ impl HomeView { sections.push(saved_farm_summary_card); } + if !projection.reminders.is_empty() { + sections.push(self.render_today_reminder_strip(&projection.reminders.items, cx)); + } + if let Some(next_window) = projection.next_fulfillment_window.as_ref() { sections.push( home_next_fulfillment_window_card( @@ -2738,6 +2743,14 @@ impl HomeView { cx, )), )) + .when(!projection.reminders.is_empty(), |this| { + this.child(self.render_reminder_feed_card( + "orders-reminders", + AppTextKey::OrdersRemindersTitle, + &projection.reminders.items, + cx, + )) + }) .child(if projection.list.is_empty() { orders_empty_state_card(projection.query.filter).into_any_element() } else { @@ -2768,7 +2781,7 @@ impl HomeView { fn render_pack_day_content( &mut self, runtime: &DesktopAppRuntimeSummary, - _: &mut Context<Self>, + cx: &mut Context<Self>, ) -> AnyElement { let projection = &runtime.pack_day_projection.projection; let Some(fulfillment_window) = projection.fulfillment_window.as_ref() else { @@ -2784,6 +2797,14 @@ impl HomeView { .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) .mx_auto() .child(pack_day_title_row(runtime)) + .when(!projection.reminders.is_empty(), |this| { + this.child(self.render_reminder_feed_card( + "pack-day-reminders", + AppTextKey::PackDayRemindersTitle, + &projection.reminders.items, + cx, + )) + }) .child(pack_day_window_summary_card(fulfillment_window)) .when(!projection.totals_by_product.is_empty(), |this| { this.child(pack_day_totals_card(&projection.totals_by_product)) @@ -2803,6 +2824,230 @@ impl HomeView { .into_any_element() } + fn render_today_reminder_strip( + &mut self, + reminders: &[ReminderDeadlineProjection], + cx: &mut Context<Self>, + ) -> AnyElement { + app_surface_card( + app_stack_v(APP_UI_THEME.foundation.spacing.tight_px) + .w_full() + .child(app_text_label(app_shared_text( + AppTextKey::HomeTodayRemindersTitle, + ))) + .child( + app_cluster(APP_UI_THEME.foundation.spacing.tight_px) + .w_full() + .items_start() + .children( + reminders + .iter() + .enumerate() + .map(|(index, reminder)| { + self.render_today_reminder_chip(index, reminder, cx) + }) + .collect::<Vec<_>>(), + ), + ), + ) + .into_any_element() + } + + fn render_today_reminder_chip( + &mut self, + index: usize, + reminder: &ReminderDeadlineProjection, + cx: &mut Context<Self>, + ) -> AnyElement { + let content = div() + .w_full() + .min_w_0() + .p(px(APP_UI_THEME.shells.home_card_padding_px)) + .flex() + .flex_col() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .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(reminder_urgency_badge(reminder.urgency)), + ) + .child( + div() + .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)), + ); + let shell = div().min_w(px(244.0)).max_w(px(296.0)).flex_1(); + + match reminder_action_target(reminder) { + Some(ReminderActionTarget::OrderDetail(order_id)) => shell + .child(app_button_card( + ("today-reminder-chip", index), + false, + cx.listener(move |this, _, _, cx| this.open_order_detail(order_id, cx)), + cx, + content, + )) + .into_any_element(), + Some(ReminderActionTarget::PackDay(fulfillment_window_id)) => shell + .child(app_button_card( + ("today-reminder-chip", index), + false, + cx.listener(move |this, _, _, cx| { + this.open_pack_day(Some(fulfillment_window_id), cx) + }), + cx, + content, + )) + .into_any_element(), + None => shell + .child( + div() + .w_full() + .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) + .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) + .child(content), + ) + .into_any_element(), + } + } + + fn render_reminder_feed_card( + &mut self, + scope: &'static str, + title_key: AppTextKey, + reminders: &[ReminderDeadlineProjection], + cx: &mut Context<Self>, + ) -> AnyElement { + let mut rows = Vec::with_capacity(reminders.len().saturating_mul(2)); + for (index, reminder) in reminders.iter().enumerate() { + rows.push(self.render_reminder_feed_row(scope, index, reminder, cx)); + if index + 1 < reminders.len() { + rows.push(section_divider().into_any_element()); + } + } + + home_card( + app_shared_text(title_key), + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) + .children(rows), + ) + .into_any_element() + } + + fn render_reminder_feed_row( + &mut self, + scope: &'static str, + index: usize, + reminder: &ReminderDeadlineProjection, + cx: &mut Context<Self>, + ) -> AnyElement { + let action = self.render_reminder_action(scope, index, reminder, cx); + + 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() + .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(reminder_urgency_badge(reminder.urgency)), + ) + .child(home_body_text(reminder.detail.clone())) + .child( + div() + .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)), + ) + .when_some(action, |this, action| this.child(div().child(action))) + .into_any_element() + } + + fn render_reminder_action( + &mut self, + scope: &'static str, + index: usize, + 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_compact( + (scope, index), + SharedString::from(label), + cx.listener(move |this, _, _, cx| this.open_order_detail(order_id, cx)), + cx, + ) + .into_any_element(), + ), + Some(ReminderActionTarget::PackDay(fulfillment_window_id)) => Some( + action_button_compact( + (scope, index), + SharedString::from(label), + cx.listener(move |this, _, _, cx| { + this.open_pack_day(Some(fulfillment_window_id), cx) + }), + cx, + ) + .into_any_element(), + ), + None => None, + } + } + fn render_products_table_card( &mut self, rows: &[ProductsListRow], @@ -8366,6 +8611,59 @@ fn pack_day_label_value_row(label: &str, value: &str) -> AnyElement { .into_any_element() } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ReminderActionTarget { + OrderDetail(OrderId), + PackDay(FulfillmentWindowId), +} + +fn reminder_action_target(reminder: &ReminderDeadlineProjection) -> Option<ReminderActionTarget> { + reminder + .order_id + .map(ReminderActionTarget::OrderDetail) + .or_else(|| { + reminder + .fulfillment_window_id + .map(ReminderActionTarget::PackDay) + }) +} + +fn reminder_urgency_key(urgency: ReminderUrgency) -> AppTextKey { + match urgency { + ReminderUrgency::Upcoming => AppTextKey::ReminderUrgencyUpcoming, + ReminderUrgency::DueSoon => AppTextKey::ReminderUrgencyDueSoon, + ReminderUrgency::Overdue => AppTextKey::ReminderUrgencyOverdue, + ReminderUrgency::Blocking => AppTextKey::ReminderUrgencyBlocking, + } +} + +fn reminder_urgency_color(urgency: ReminderUrgency) -> u32 { + match urgency { + ReminderUrgency::Upcoming => APP_UI_THEME.components.app_status_indicator.offline, + ReminderUrgency::DueSoon => APP_UI_THEME.foundation.text.accent, + ReminderUrgency::Overdue | ReminderUrgency::Blocking => { + APP_UI_THEME.components.app_status_indicator.attention + } + } +} + +fn reminder_urgency_badge(urgency: ReminderUrgency) -> AnyElement { + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(reminder_urgency_color(urgency))) + .child(app_shared_text(reminder_urgency_key(urgency))) + .into_any_element() +} + +fn reminder_deadline_text(reminder: &ReminderDeadlineProjection) -> String { + format!( + "{}: {}", + app_text(AppTextKey::ReminderDeadlineLabel), + reminder.deadline_at + ) +} + fn products_empty_state_card(filter: ProductsFilter) -> impl IntoElement { let (title_key, body_key) = if filter == ProductsFilter::NeedAttention { ( @@ -9966,19 +10264,21 @@ fn home_farm_order_method_label_key(method: FarmOrderMethod) -> AppTextKey { #[cfg(test)] mod tests { use super::{ - AppTextKey, FarmerHomeFarmState, HomeStage, SETTINGS_FARM_PANEL_SECTIONS, - SETTINGS_NAVIGATION_ORDER, SETTINGS_OPERATIONS_PANEL_SECTIONS, - SettingsInventorySectionSpec, SettingsPanelViewKey, StartupHomeSurface, - StartupSignerConnectState, about_conflict_action_specs, about_conflict_aggregate_text, - about_conflict_detail_rows, about_conflict_review_body_key, about_manual_refresh_enabled, - about_runtime_rows, about_status_rows, app_text, buyer_orders_status_key, - farm_setup_onboarding_card_spec, farmer_home_farm_state, 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, 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, + APP_UI_THEME, AppTextKey, FarmerHomeFarmState, HomeStage, ReminderActionTarget, + SETTINGS_FARM_PANEL_SECTIONS, SETTINGS_NAVIGATION_ORDER, + SETTINGS_OPERATIONS_PANEL_SECTIONS, SettingsInventorySectionSpec, SettingsPanelViewKey, + StartupHomeSurface, StartupSignerConnectState, about_conflict_action_specs, + about_conflict_aggregate_text, about_conflict_detail_rows, about_conflict_review_body_key, + about_manual_refresh_enabled, about_runtime_rows, about_status_rows, app_text, + buyer_orders_status_key, farm_setup_onboarding_card_spec, farmer_home_farm_state, + 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, + 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, }; use crate::runtime::{ DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSyncConflictSummary, @@ -9989,8 +10289,9 @@ mod tests { ActiveSurface, AppStartupGate, BuyerOrderStatus, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, FulfillmentWindowSummary, LoggedOutStartupPhase, LoggedOutStartupProjection, - PackDayProjection, PersonalSection, ShellSection, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, + PackDayProjection, PersonalSection, ReminderDeadlineProjection, ReminderDeliveryState, + ReminderId, ReminderKind, ReminderSurface, ReminderUrgency, ShellSection, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, @@ -10498,6 +10799,79 @@ mod tests { } #[test] + fn reminder_action_target_prefers_order_detail_before_pack_day() { + let order_id = radroots_app_models::OrderId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + + assert_eq!( + reminder_action_target(&fixture_reminder( + Some(order_id), + Some(fulfillment_window_id), + ReminderKind::OrderAction, + ReminderUrgency::DueSoon, + )), + Some(ReminderActionTarget::OrderDetail(order_id)) + ); + assert_eq!( + reminder_action_target(&fixture_reminder( + None, + Some(fulfillment_window_id), + ReminderKind::FulfillmentWindow, + ReminderUrgency::Upcoming, + )), + Some(ReminderActionTarget::PackDay(fulfillment_window_id)) + ); + assert_eq!( + reminder_action_target(&fixture_reminder( + None, + None, + ReminderKind::SyncImpact, + ReminderUrgency::Blocking, + )), + None + ); + } + + #[test] + fn reminder_urgency_helpers_follow_the_surface_contract() { + assert_eq!( + reminder_urgency_key(ReminderUrgency::Upcoming), + AppTextKey::ReminderUrgencyUpcoming + ); + assert_eq!( + reminder_urgency_key(ReminderUrgency::DueSoon), + AppTextKey::ReminderUrgencyDueSoon + ); + assert_eq!( + reminder_urgency_color(ReminderUrgency::Upcoming), + APP_UI_THEME.components.app_status_indicator.offline + ); + assert_eq!( + reminder_urgency_color(ReminderUrgency::DueSoon), + APP_UI_THEME.foundation.text.accent + ); + assert_eq!( + reminder_urgency_color(ReminderUrgency::Blocking), + APP_UI_THEME.components.app_status_indicator.attention + ); + } + + #[test] + fn reminder_deadline_text_uses_the_typed_due_label() { + let reminder = fixture_reminder( + None, + Some(FulfillmentWindowId::new()), + ReminderKind::FulfillmentWindow, + ReminderUrgency::Upcoming, + ); + + assert_eq!( + reminder_deadline_text(&reminder), + format!("{}: {}", app_text(AppTextKey::ReminderDeadlineLabel), "0") + ); + } + + #[test] fn about_status_rows_disable_sync_without_a_selected_account() { let rows = about_status_rows(&summary( HomeRoute::SetupRequired, @@ -10736,4 +11110,26 @@ mod tests { client_secret_key_hex: client_identity.secret_key_hex(), } } + + fn fixture_reminder( + order_id: Option<radroots_app_models::OrderId>, + fulfillment_window_id: Option<FulfillmentWindowId>, + kind: ReminderKind, + urgency: ReminderUrgency, + ) -> ReminderDeadlineProjection { + ReminderDeadlineProjection { + reminder_id: ReminderId::new(), + farm_id: FarmId::new(), + order_id, + fulfillment_window_id, + kind, + surface: ReminderSurface::Orders, + urgency, + title: String::new(), + detail: String::new(), + deadline_at: "0".to_owned(), + action_label: None, + delivery_state: ReminderDeliveryState::Scheduled, + } + } } diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -181,13 +181,21 @@ define_app_text_keys! { OrdersDetailStatusLabel => "orders.detail.status.label", OrdersDetailWindowLabel => "orders.detail.window.label", OrdersDetailPickupLabel => "orders.detail.pickup.label", + OrdersRemindersTitle => "orders.reminders.title", PackDayTitle => "pack_day.title", + PackDayRemindersTitle => "pack_day.reminders.title", PackDayWindowSummaryTitle => "pack_day.window_summary.title", PackDayTotalsTitle => "pack_day.totals.title", PackDayPackListTitle => "pack_day.pack_list.title", PackDayPickupRosterTitle => "pack_day.pickup_roster.title", PackDayEmptyTitle => "pack_day.empty.title", PackDayEmptyBody => "pack_day.empty.body", + HomeTodayRemindersTitle => "home.today.reminders.title", + ReminderDeadlineLabel => "reminder.deadline.label", + ReminderUrgencyUpcoming => "reminder.urgency.upcoming", + ReminderUrgencyDueSoon => "reminder.urgency.due_soon", + ReminderUrgencyOverdue => "reminder.urgency.overdue", + ReminderUrgencyBlocking => "reminder.urgency.blocking", 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 @@ -168,6 +168,19 @@ mod tests { } #[test] + fn english_reminder_copy_matches_the_seller_surface_contract() { + assert_eq!(app_text(AppTextKey::HomeTodayRemindersTitle), "Coming up"); + assert_eq!(app_text(AppTextKey::OrdersRemindersTitle), "Reminders"); + 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"); + } + + #[test] fn english_about_copy_matches_the_runtime_status_contract() { assert_eq!( app_text(AppTextKey::SettingsAboutStatusSectionLabel), diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -23,6 +23,7 @@ "home.today.open_in_orders.action": "View all", "home.today.open_in_pack_day.action": "Open pack day", "home.today.open_in_products.action": "Open in Products", + "home.today.reminders.title": "Coming up", "home.today.setup_checklist": "Setup checklist", "home.today.next_fulfillment_window": "Next fulfillment window", "home.today.window.starts": "Starts", @@ -160,13 +161,20 @@ "orders.detail.status.label": "Status", "orders.detail.window.label": "Fulfillment window", "orders.detail.pickup.label": "Pickup location", + "orders.reminders.title": "Reminders", "pack_day.title": "Pack day", + "pack_day.reminders.title": "Before this window", "pack_day.window_summary.title": "Window summary", "pack_day.totals.title": "Totals by product", "pack_day.pack_list.title": "Pack list", "pack_day.pickup_roster.title": "Pickup roster", "pack_day.empty.title": "Nothing to pack yet", "pack_day.empty.body": "Orders for this window will appear here when they are ready to pack.", + "reminder.deadline.label": "Due", + "reminder.urgency.upcoming": "Upcoming", + "reminder.urgency.due_soon": "Due soon", + "reminder.urgency.overdue": "Overdue", + "reminder.urgency.blocking": "Blocking", "products.title": "Products", "products.filters.title": "View", "products.search.placeholder": "Search products",