From 00db014eaedcf5f27ebe37e12a347d1d9e599839 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 23 Sep 2026 22:36:13 +0200 Subject: [PATCH] A card with no button is read, never picked The opener that says what the business does and the look-around card lead nowhere: they are what the page says before its options, so a task and a simulation step carry them as `page` context and offer only the cards that lead somewhere. Five personas per round had been "choosing" the opener and counted as misroutes. Co-Authored-By: Claude Fable 5.1 --- src/needs.rs | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/needs.rs b/src/needs.rs index cb20a8c..10eb702 100644 --- a/src/needs.rs +++ b/src/needs.rs @@ -571,7 +571,8 @@ pub fn sim_model( json!({ "page": step.page, "question": page.name, - "options": page.alternatives.iter().filter(|a| !a.disabled).map(|a| json!({ + "context": page_context(page), + "options": page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).map(|a| json!({ "name": a.name, "description": plain(&a.description), })).collect::>(), @@ -646,6 +647,41 @@ fn plain(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } +/// A card a visitor can act on: it leads to a page, records an +/// answer, or moves a record. A card with none of those is read, not +/// picked - the opener that says what the business does, the look +/// around - and is context on the page, never an option. +pub fn leads_somewhere(a: &Alternative) -> bool { + a.action.is_some() || a.record_as.is_some() || a.self_transition.is_some() +} + +/// What the page says before its options: every card that leads +/// nowhere, its heading, description and features, as one line each. +pub fn page_context(page: &Question) -> String { + page.alternatives + .iter() + .filter(|a| !a.disabled && !leads_somewhere(a)) + .map(|a| { + let mut line = a.name.clone(); + let description = plain(&a.description); + if !description.is_empty() { + line.push_str(&format!(": {description}")); + } + for f in &a.features { + let text = plain(&f.description); + match (f.name.trim().is_empty(), text.is_empty()) { + (true, true) => {} + (true, false) => line.push_str(&format!(" {text}")), + (false, true) => line.push_str(&format!(" {}.", f.name.trim())), + (false, false) => line.push_str(&format!(" {}: {text}", f.name.trim())), + } + } + line + }) + .collect::>() + .join("\n") +} + /// Every (persona, page-on-their-path) pair with a real choice on it. /// `skim` shows the engine only each alternative's heading - what a /// visitor who never reads the description has to go on. @@ -660,10 +696,11 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec { }; for (i, step) in path.iter().enumerate() { let page = &questions[&step.page]; - let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled).collect(); + let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect(); if open.len() < 2 { continue; } + let context = page_context(page); let mut trail: Vec = path[..=i].iter().map(|s| s.page.clone()).collect(); let expect: Vec = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) .into_iter() @@ -684,7 +721,11 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec { persona: persona.id.clone(), need: need.id.clone(), page: step.page.clone(), - state: BTreeMap::from([("body", plain(&persona.text))]), + state: if context.is_empty() { + BTreeMap::from([("body", plain(&persona.text))]) + } else { + BTreeMap::from([("body", plain(&persona.text)), ("page", context)]) + }, questions: BTreeMap::from([( "pick", ChoiceQuestion {