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