Add species-level data fallback on cultivar detail page

When cultivar fields are empty, show species-level data as fallback with
italic styling and a "~ species" indicator. Applied to: frost tolerance
(from USDA zone), light requirement (inferred from plant layer), and
climate card (drought tolerance, USDA hardiness zone). Includes a footer
note explaining estimated values.
This commit is contained in:
2026-03-15 16:12:06 +01:00
parent a7f64e763f
commit 2f909b98d5
2 changed files with 126 additions and 3 deletions
+22
View File
@@ -1197,6 +1197,28 @@ td.placeholder {
text-decoration: underline;
}
/* Species-fallback estimated values */
.estimated-value {
color: var(--accent);
font-style: italic;
}
.estimated-tag {
font-size: 0.7rem;
color: var(--text-muted);
margin-left: 0.4rem;
opacity: 0.7;
}
.species-fallback-note {
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
margin-top: 1.25rem;
opacity: 0.7;
}
/* Responsive */
@media (max-width: 768px) {
+104 -3
View File
@@ -70,6 +70,27 @@ fn opt_vec_str(val: &Option<Vec<String>>) -> String {
}
}
/// Infer a light requirement string from a species plant_layer value.
fn infer_light_from_layer(layer: &Option<String>) -> Option<String> {
match layer.as_deref() {
Some(l) => {
let lower = l.to_lowercase();
if lower.contains("canopy") || lower.contains("tall tree") {
Some("Full sun".to_string())
} else if lower.contains("understory") || lower.contains("small tree") || lower.contains("shrub") {
Some("Partial shade".to_string())
} else if lower.contains("ground") || lower.contains("creep") {
Some("Shade tolerant".to_string())
} else if lower.contains("herb") || lower.contains("climber") || lower.contains("vine") {
Some("Full sun to partial shade".to_string())
} else {
None
}
}
None => None,
}
}
const STORAGE_KEY_COLS: &str = "herbapi_cultivars_cols";
const STORAGE_KEY_PP: &str = "herbapi_cultivars_pp";
@@ -385,6 +406,33 @@ pub fn CultivarDetail(slug: String) -> Element {
let min_light_h = opt_f64_suffix(c.min_light_hours_day, " h");
let opt_light_h = opt_f64_suffix(c.optimal_light_hours_day, " h");
// --- Species fallback values ---
// When a cultivar field is empty, we show species-level
// data styled as an estimation.
let (sp_frost_fb, sp_light_fb, sp_drought_fb, sp_usda_fb) = {
let sp_read = species_data.read();
match &*sp_read {
Some(Some(sp)) => {
let frost = sp.hardiness_zone_usda.as_ref()
.filter(|s| !s.is_empty())
.map(|z| format!("USDA Zone {z}"));
let light = infer_light_from_layer(&sp.plant_layer);
let drought = sp.drought_tolerance.clone()
.filter(|s| !s.is_empty());
let usda = sp.hardiness_zone_usda.clone()
.filter(|s| !s.is_empty());
(frost, light, drought, usda)
}
_ => (None, None, None, None),
}
};
let has_any_fallback =
(frost_tol == "\u{2014}" && sp_frost_fb.is_some())
|| (light_req == "\u{2014}" && sp_light_fb.is_some())
|| (light == "\u{2014}" && sp_light_fb.is_some())
|| sp_drought_fb.is_some()
|| sp_usda_fb.is_some();
// Species image: prefer primary from images API, else primary_image_key
let sp_primary_img: Option<crate::types::Image> = {
let imgs = species_images.read();
@@ -673,7 +721,16 @@ pub fn CultivarDetail(slug: String) -> Element {
}
tr {
th { "Light Requirement" }
td { class: if light_req == "\u{2014}" { "placeholder" } else { "" }, "{light_req}" }
if light_req != "\u{2014}" {
td { "{light_req}" }
} else if let Some(ref fb) = sp_light_fb {
td {
span { class: "estimated-value", "{fb}" }
span { class: "estimated-tag", "~ species" }
}
} else {
td { class: "placeholder", "\u{2014}" }
}
}
tr {
th { "Stratification Required" }
@@ -710,11 +767,29 @@ pub fn CultivarDetail(slug: String) -> Element {
}
tr {
th { "Light" }
td { class: if light == "\u{2014}" { "placeholder" } else { "" }, "{light}" }
if light != "\u{2014}" {
td { "{light}" }
} else if let Some(ref fb) = sp_light_fb {
td {
span { class: "estimated-value", "{fb}" }
span { class: "estimated-tag", "~ species" }
}
} else {
td { class: "placeholder", "\u{2014}" }
}
}
tr {
th { "Frost Tolerance" }
td { class: if frost_tol == "\u{2014}" { "placeholder" } else { "" }, "{frost_tol}" }
if frost_tol != "\u{2014}" {
td { "{frost_tol}" }
} else if let Some(ref fb) = sp_frost_fb {
td {
span { class: "estimated-value", "{fb}" }
span { class: "estimated-tag", "~ species" }
}
} else {
td { class: "placeholder", "\u{2014}" }
}
}
tr {
th { "Min Light Hours/Day" }
@@ -724,6 +799,24 @@ pub fn CultivarDetail(slug: String) -> Element {
th { "Optimal Light Hours/Day" }
td { class: if opt_light_h == "\u{2014}" { "placeholder" } else { "" }, "{opt_light_h}" }
}
if let Some(ref fb) = sp_drought_fb {
tr {
th { "Drought Tolerance" }
td {
span { class: "estimated-value", "{fb}" }
span { class: "estimated-tag", "~ species" }
}
}
}
if let Some(ref fb) = sp_usda_fb {
tr {
th { "USDA Hardiness Zone" }
td {
span { class: "estimated-value", "{fb}" }
span { class: "estimated-tag", "~ species" }
}
}
}
}
}
}
@@ -810,6 +903,14 @@ pub fn CultivarDetail(slug: String) -> Element {
}
}
}
if has_any_fallback {
p { class: "species-fallback-note",
"Values marked "
span { class: "estimated-tag", "~ species" }
" are estimated from species-level data."
}
}
}
},
}