commit dd429d5c16efe7c2f2ec1b79a6a316021d23023a
parent 33029ce6c297d1af66a4931f563e930b6d22a311
Author: triesap <tyson@radroots.org>
Date: Sat, 8 Aug 2026 00:50:27 +0000
feat(mobile): expose typed Today rendering fields
- preserve Event and FoodAvailability locations in projected cards
- expose focused price, currency, unit, and quantity values through UniFFI
- keep older stored projections compatible through optional serde fields
- cover exact rendering-field projection with focused protocol tests
Diffstat:
3 files changed, 94 insertions(+), 0 deletions(-)
diff --git a/crates/mobile_core/src/runtime/product_surface/model.rs b/crates/mobile_core/src/runtime/product_surface/model.rs
@@ -187,6 +187,16 @@ pub struct ClassifiedCard {
pub effective_at: u64,
pub event_start: Option<u64>,
pub event_end: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub location: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub price_amount: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub price_currency: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub price_unit: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub quantity: Option<String>,
pub context_rank: ContextRank,
pub inclusion_reason: String,
pub media: Vec<MediaReference>,
diff --git a/crates/mobile_core/src/runtime/product_surface/projection.rs b/crates/mobile_core/src/runtime/product_surface/projection.rs
@@ -147,6 +147,24 @@ fn card(
};
let tags = event.tags_as_vec();
let title = tag_value(&tags, &["title", "name"]);
+ let location = matches!(
+ card_type,
+ TodayCardType::Event | TodayCardType::FoodAvailability
+ )
+ .then(|| tag_value(&tags, &["location"]))
+ .flatten();
+ let price = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_values(&tags, "price"))
+ .flatten();
+ let price_amount = price.as_ref().and_then(|values| values.first().cloned());
+ let price_currency = price.as_ref().and_then(|values| values.get(1).cloned());
+ let price_unit = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_value(&tags, &["radroots:price_unit"]))
+ .flatten();
+ let quantity = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_values(&tags, "radroots:quantity"))
+ .flatten()
+ .and_then(|values| values.first().cloned());
let (effective_at, event_start, event_end) = match card_type {
TodayCardType::Event => {
let start = tag_time(&tags, "start").unwrap_or_else(|| event.created_at_u64());
@@ -175,6 +193,11 @@ fn card(
effective_at,
event_start,
event_end,
+ location,
+ price_amount,
+ price_currency,
+ price_unit,
+ quantity,
context_rank: context.rank,
inclusion_reason: context.reason.to_owned(),
media,
@@ -192,6 +215,12 @@ fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> {
})
}
+fn tag_values(tags: &[Vec<String>], name: &str) -> Option<Vec<String>> {
+ tags.iter().find_map(|tag| {
+ (tag.first().map(String::as_str) == Some(name) && tag.len() > 1).then(|| tag[1..].to_vec())
+ })
+}
+
fn tag_time(tags: &[Vec<String>], name: &str) -> Option<u64> {
let value = tag_value(tags, &[name])?;
value.parse().ok().or_else(|| {
@@ -338,6 +367,13 @@ mod tests {
}
}
+ fn card(event: RadrootsAdmittedEvent) -> ClassifiedCard {
+ match classify_admitted_event(&event, context()) {
+ ProductEventClassification::Card(card) => *card,
+ other => panic!("expected card, got {other:?}"),
+ }
+ }
+
#[test]
fn exact_five_card_classifier_precedence_is_protocol_structural() {
assert_eq!(
@@ -403,6 +439,44 @@ mod tests {
}
#[test]
+ fn event_and_food_cards_preserve_required_rendering_fields() {
+ let event = card(admitted(
+ 31_923,
+ vec![
+ vec!["d", "market-2026"],
+ vec!["title", "Saturday market"],
+ vec!["start", "2000000100"],
+ vec!["D", "23148"],
+ vec!["location", "Town square"],
+ ],
+ "Farm market",
+ ));
+ assert_eq!(event.location.as_deref(), Some("Town square"));
+ assert_eq!(event.price_amount, None);
+
+ let food = card(admitted(
+ 30_402,
+ vec![
+ vec!["d", "carrots"],
+ vec!["title", "Carrots"],
+ vec!["summary", "Fresh bunches"],
+ vec!["published_at", "1999999999"],
+ vec!["location", "Saanich"],
+ vec!["price", "3", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["radroots:quantity", "12", "lb"],
+ vec!["status", "active"],
+ ],
+ "Carrots available",
+ ));
+ assert_eq!(food.location.as_deref(), Some("Saanich"));
+ assert_eq!(food.price_amount.as_deref(), Some("3"));
+ assert_eq!(food.price_currency.as_deref(), Some("CAD"));
+ assert_eq!(food.price_unit.as_deref(), Some("lb"));
+ assert_eq!(food.quantity.as_deref(), Some("12"));
+ }
+
+ #[test]
fn ordinary_standard_kind_one_needs_no_product_marker() {
let event = admitted(1, vec![vec!["t", "gardening"]], "Seedlings are ready");
let ProductEventClassification::Card(card) = classify_admitted_event(&event, context())
diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs
@@ -403,6 +403,11 @@ pub struct FfiTodayCardRecord {
pub effective_at_unix_s: u64,
pub event_start_unix_s: Option<u64>,
pub event_end_unix_s: Option<u64>,
+ pub location: Option<String>,
+ pub price_amount: Option<String>,
+ pub price_currency: Option<String>,
+ pub price_unit: Option<String>,
+ pub quantity: Option<String>,
pub context_rank: u8,
pub inclusion_reason: String,
pub media: Vec<FfiMediaReferenceRecord>,
@@ -432,6 +437,11 @@ impl From<TodayCard> for FfiTodayCardRecord {
effective_at_unix_s: card.effective_at,
event_start_unix_s: card.event_start,
event_end_unix_s: card.event_end,
+ location: card.location,
+ price_amount: card.price_amount,
+ price_currency: card.price_currency,
+ price_unit: card.price_unit,
+ quantity: card.quantity,
context_rank: card.context_rank.value(),
inclusion_reason: card.inclusion_reason,
media: card.media.into_iter().map(Into::into).collect(),