app

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

commit 718d88f95583b5a5198722af164c467dec6c3e94
parent 0d3893f5ea8dad9b9b2280570091ec96171fc6da
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 02:36:04 +0000

buyer: close repeat-demand truth gaps

- require current stock to satisfy full historical repeat-demand quantities
- move repeat-demand user copy out of sqlite and into shared i18n keys
- render localized repeat-demand actions and notes from typed projection state
- cover stock shortfalls and copy guards in the mounted app test suite

Diffstat:
Mcrates/launchers/desktop/src/source_guards.rs | 5+++++
Mcrates/launchers/desktop/src/window.rs | 40+++++++++++++++++++++++++++++++++++++---
Mcrates/shared/i18n/src/keys.rs | 5+++++
Mcrates/shared/i18n/src/lib.rs | 20++++++++++++++++++++
Mcrates/shared/models/src/lib.rs | 4----
Mcrates/shared/sqlite/src/buyer.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mi18n/locales/en/messages.json | 5+++++
7 files changed, 189 insertions(+), 40 deletions(-)

diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -327,6 +327,11 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrdersDetailNoteLabel", "AppTextKey::PersonalOrdersDetailItemsTitle", "AppTextKey::PersonalOrdersRepeatDemandTitle", + "AppTextKey::PersonalOrdersRepeatDemandActionEligible", + "AppTextKey::PersonalOrdersRepeatDemandActionPartial", + "AppTextKey::PersonalOrdersRepeatDemandNotePartialSingle", + "AppTextKey::PersonalOrdersRepeatDemandNotePartialMultiple", + "AppTextKey::PersonalOrdersRepeatDemandNoteUnavailable", "AppTextKey::PersonalOrdersStatusPlaced", "AppTextKey::PersonalOrdersStatusScheduled", "AppTextKey::PersonalOrdersStatusReady", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -25,7 +25,8 @@ use radroots_app_models::{ ProductListRow, ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, RecoveryKind, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderLogEntryProjection, ReminderLogProjection, - ReminderSurface, ReminderUrgency, RepeatDemandEligibility, ShellSection, + ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, + ShellSection, TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ @@ -7868,7 +7869,7 @@ fn buyer_order_detail_card( app_shared_text(AppTextKey::PersonalOrdersRepeatDemandTitle), app_stack_v(APP_UI_THEME.foundation.spacing.small_px) .w_full() - .when_some(repeat_demand.note.clone(), |this, note| { + .when_some(buyer_repeat_demand_note(repeat_demand), |this, note| { this.child(home_body_text(note)) }) .when_some(repeat_confirmation, |this, replace_confirmation| { @@ -7927,7 +7928,7 @@ fn buyer_order_detail_card( |this| { this.child(action_button_primary( "buyer-order-repeat-demand", - SharedString::from(repeat_demand.action_label.clone()), + buyer_repeat_demand_action_label(repeat_demand), cx.listener({ let order_id = detail.order_id; move |this, _, _, cx| { @@ -7944,6 +7945,39 @@ fn buyer_order_detail_card( .into_any_element() } +fn buyer_repeat_demand_action_label( + repeat_demand: &RepeatDemandHandoffProjection, +) -> SharedString { + match repeat_demand.eligibility { + RepeatDemandEligibility::Eligible => { + app_shared_text(AppTextKey::PersonalOrdersRepeatDemandActionEligible) + } + RepeatDemandEligibility::Partial => { + app_shared_text(AppTextKey::PersonalOrdersRepeatDemandActionPartial) + } + RepeatDemandEligibility::Unavailable => { + app_shared_text(AppTextKey::PersonalOrdersRepeatDemandActionEligible) + } + } +} + +fn buyer_repeat_demand_note( + repeat_demand: &RepeatDemandHandoffProjection, +) -> Option<SharedString> { + match repeat_demand.eligibility { + RepeatDemandEligibility::Eligible => None, + RepeatDemandEligibility::Partial if repeat_demand.unavailable_item_count == 1 => Some( + app_shared_text(AppTextKey::PersonalOrdersRepeatDemandNotePartialSingle), + ), + RepeatDemandEligibility::Partial => Some(app_shared_text( + AppTextKey::PersonalOrdersRepeatDemandNotePartialMultiple, + )), + RepeatDemandEligibility::Unavailable => Some(app_shared_text( + AppTextKey::PersonalOrdersRepeatDemandNoteUnavailable, + )), + } +} + fn buyer_order_detail_empty_card() -> impl IntoElement { home_card( app_shared_text(AppTextKey::PersonalOrdersDetailTitle), diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -121,6 +121,11 @@ define_app_text_keys! { PersonalOrdersDetailNoteLabel => "personal.orders.detail.note.label", PersonalOrdersDetailItemsTitle => "personal.orders.detail.items.title", PersonalOrdersRepeatDemandTitle => "personal.orders.repeat_demand.title", + PersonalOrdersRepeatDemandActionEligible => "personal.orders.repeat_demand.action.eligible", + PersonalOrdersRepeatDemandActionPartial => "personal.orders.repeat_demand.action.partial", + PersonalOrdersRepeatDemandNotePartialSingle => "personal.orders.repeat_demand.note.partial_single", + PersonalOrdersRepeatDemandNotePartialMultiple => "personal.orders.repeat_demand.note.partial_multiple", + PersonalOrdersRepeatDemandNoteUnavailable => "personal.orders.repeat_demand.note.unavailable", PersonalOrdersStatusPlaced => "personal.orders.status.placed", PersonalOrdersStatusScheduled => "personal.orders.status.scheduled", PersonalOrdersStatusReady => "personal.orders.status.ready", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -404,6 +404,26 @@ mod tests { app_text(AppTextKey::PersonalOrdersRepeatDemandTitle), "Reorder" ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersRepeatDemandActionEligible), + "Reorder" + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersRepeatDemandActionPartial), + "Reorder available items" + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersRepeatDemandNotePartialSingle), + "One item from this order is currently unavailable to reorder." + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersRepeatDemandNotePartialMultiple), + "Some items from this order are currently unavailable to reorder." + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersRepeatDemandNoteUnavailable), + "Items from this order are currently unavailable to reorder." + ); } #[test] diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -2025,8 +2025,6 @@ pub struct RepeatDemandHandoffProjection { pub eligibility: RepeatDemandEligibility, pub available_item_count: u32, pub unavailable_item_count: u32, - pub action_label: String, - pub note: Option<String>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -3026,8 +3024,6 @@ mod tests { eligibility: RepeatDemandEligibility::Partial, available_item_count: 2, unavailable_item_count: 1, - action_label: "Reorder available items".to_owned(), - note: Some("One prior item is no longer listed.".to_owned()), }; let reminder_feed = ReminderFeedProjection { diff --git a/crates/shared/sqlite/src/buyer.rs b/crates/shared/sqlite/src/buyer.rs @@ -1205,7 +1205,15 @@ impl<'a> AppBuyerRepository<'a> { let mut unavailable_item_count = 0u32; for order_line in &order_lines { - if let Some(listing) = visible_listings.get(&order_line.product_id).cloned() { + if let Some(listing) = visible_listings + .get(&order_line.product_id) + .filter(|listing| { + listing + .stock_count + .is_some_and(|quantity| quantity >= order_line.quantity) + }) + .cloned() + { available_lines.push(BuyerCartLineRecord { listing, quantity: order_line.quantity, @@ -1238,8 +1246,6 @@ impl<'a> AppBuyerRepository<'a> { eligibility, available_item_count, unavailable_item_count, - action_label: repeat_demand_action_label(eligibility), - note: repeat_demand_note(available_item_count, unavailable_item_count), }, })) } @@ -1607,31 +1613,6 @@ fn parse_repeat_demand_product_id(line_id: &str) -> Result<ProductId, AppSqliteE parse_typed_id("order_lines.id", product_id.to_owned()) } -fn repeat_demand_action_label(eligibility: RepeatDemandEligibility) -> String { - match eligibility { - RepeatDemandEligibility::Eligible => "Reorder".to_owned(), - RepeatDemandEligibility::Partial => "Reorder available items".to_owned(), - RepeatDemandEligibility::Unavailable => "Unavailable".to_owned(), - } -} - -fn repeat_demand_note( - available_item_count: u32, - unavailable_item_count: u32, -) -> Option<String> { - match (available_item_count, unavailable_item_count) { - (0, 0) => None, - (_, 0) => Some("All items from this order are currently available.".to_owned()), - (0, _) => Some("The items from this order are no longer available.".to_owned()), - (_, unavailable) if unavailable == 1 => { - Some("One item from this order is no longer available.".to_owned()) - } - (_, unavailable) => Some(format!( - "{unavailable} items from this order are no longer available." - )), - } -} - fn refresh_buyer_cart_summary(cart: &mut BuyerCartProjection) -> Result<(), AppSqliteError> { if cart.lines.is_empty() { cart.subtotal_minor_units = None; @@ -1856,7 +1837,7 @@ mod tests { }; use rusqlite::{Connection, params}; - use crate::{AppSqliteError, AppSqliteStore, DatabaseTarget}; + use crate::{AppSqliteError, AppSqliteStore, BuyerRepeatDemandApplyOutcome, DatabaseTarget}; use super::AppBuyerRepository; @@ -2202,12 +2183,115 @@ mod tests { ); assert_eq!(row_repeat_demand.available_item_count, 1); assert_eq!(row_repeat_demand.unavailable_item_count, 1); - assert_eq!(row_repeat_demand.action_label, "Reorder available items"); + assert_eq!(detail_repeat_demand, row_repeat_demand); + } + + #[test] + fn buyer_repeat_demand_requires_current_stock_for_full_historical_quantity() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + let repository = AppBuyerRepository::new(connection); + let context = BuyerContext::Guest; + let farm_id = insert_farm(connection, "Willow Farm", "ready"); + let pickup_location_id = insert_pickup_location(connection, farm_id, "Barn pickup"); + let future_window_id = insert_window( + connection, + farm_id, + Some(pickup_location_id), + "Friday pickup", + "2099-04-18T16:00:00Z", + "2099-04-18T18:00:00Z", + ); + + insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false); + let product_id = insert_product( + connection, + farm_id, + SeedProduct { + title: "Salad mix", + subtitle: "Spring blend", + status: "published", + unit_label: "bag", + price_minor_units: Some(650), + price_currency: "USD", + stock_count: Some(8), + availability_window_id: Some(future_window_id), + }, + ); + let listing = repository + .load_buyer_product_detail(product_id) + .expect("buyer detail should load") + .expect("listing should exist") + .listing; + + repository + .replace_buyer_cart( + &context, + &radroots_app_models::BuyerCartProjection { + farm_id: Some(farm_id), + farm_display_name: Some("Willow Farm".to_owned()), + lines: vec![radroots_app_models::BuyerCartLineProjection { + product_id: listing.product_id, + farm_id: listing.farm_id, + farm_display_name: listing.farm_display_name.clone(), + title: listing.title.clone(), + quantity: 2, + unit_price: listing.price.clone(), + line_total_minor_units: 1300, + fulfillment_summary: "Friday pickup".to_owned(), + }], + subtotal_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + replace_confirmation: None, + }, + ) + .expect("buyer cart should save"); + repository + .save_buyer_checkout_draft( + &context, + &radroots_app_models::BuyerCheckoutDraft { + name: "Casey Buyer".to_owned(), + email: "casey@example.com".to_owned(), + phone: String::new(), + order_note: String::new(), + }, + ) + .expect("buyer checkout draft should save"); + let order_id = repository + .place_buyer_order(&context) + .expect("buyer checkout should place order"); + + connection + .execute( + "update products set stock_count = 1 where id = ?1", + params![product_id.to_string()], + ) + .expect("product stock should lower"); + + let repeat_demand = repository + .load_buyer_order_detail(&context, order_id) + .expect("buyer order detail should load") + .expect("buyer order detail should exist") + .repeat_demand + .expect("repeat demand should stay visible for unavailable reorder"); + assert_eq!( - row_repeat_demand.note.as_deref(), - Some("One item from this order is no longer available.") + repeat_demand.eligibility, + radroots_app_models::RepeatDemandEligibility::Unavailable ); - assert_eq!(detail_repeat_demand, row_repeat_demand); + assert_eq!(repeat_demand.available_item_count, 0); + assert_eq!(repeat_demand.unavailable_item_count, 1); + assert_eq!( + repository + .apply_buyer_repeat_demand_to_cart(&context, order_id, false) + .expect("repeat demand apply should load"), + BuyerRepeatDemandApplyOutcome::Unavailable + ); + assert!(repository + .load_buyer_cart(&context) + .expect("buyer cart should reload") + .lines + .is_empty()); } #[test] diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -101,6 +101,11 @@ "personal.orders.detail.note.label": "Order note", "personal.orders.detail.items.title": "Items", "personal.orders.repeat_demand.title": "Reorder", + "personal.orders.repeat_demand.action.eligible": "Reorder", + "personal.orders.repeat_demand.action.partial": "Reorder available items", + "personal.orders.repeat_demand.note.partial_single": "One item from this order is currently unavailable to reorder.", + "personal.orders.repeat_demand.note.partial_multiple": "Some items from this order are currently unavailable to reorder.", + "personal.orders.repeat_demand.note.unavailable": "Items from this order are currently unavailable to reorder.", "personal.orders.status.placed": "Placed", "personal.orders.status.scheduled": "Scheduled", "personal.orders.status.ready": "Ready",