build: a card states what the reader must already have

`requires:` on a need is one sentence naming what the person must
have or be for the alternative to work - a membership, a reference
number, the link from a mail. It renders as the card's first feature,
above the road, who-reads-it and the form, because a condition a
reader meets only after filling the form in has already cost them the
filling in. Whether the section exists is read from the plan, never
from its wording: under a lift every slot holds a token and a token is
never empty, which would conjure a section the live page lacks and
shift every feature after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-24 20:42:41 +02:00
co-authored by Claude Opus 5
parent 743f4a8563
commit 1ed33b6160
2 changed files with 54 additions and 5 deletions
+45 -5
View File
@@ -196,6 +196,13 @@ pub struct PlanNeed {
/// start and not after, where they depart instead.
#[serde(default)]
pub turned_down_from: Vec<String>,
/// What the person must already have or be for this alternative to
/// work: a membership, a reference number, the link from a mail,
/// an account. Stated on the card before the form, because a
/// condition a reader meets only after filling something in has
/// already cost them the filling in. Empty when there is none.
#[serde(default)]
pub requires: String,
/// Who presses the button that moves a case INTO a stage, where it
/// is not `handled_by`: another group, several groups, or
/// `submitter` - the person who sent the form, through their own
@@ -628,10 +635,14 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"button": "Send",
"encouragement": "",
"icon": "",
// Two sections of context the plan already knows: the road
// a case travels, and who is at the other end. The
// fallbacks are built from the plan, so they are true
// before a model has said a word.
// Three sections of context the plan already knows: what
// the person must have for this to work, the road a case
// travels, and who is at the other end. The fallbacks are
// built from the plan, so they are true before a model has
// said a word.
"requires_title": if need.requires.trim().is_empty() { String::new() } else { "What you need".to_string() },
"requires_description": need.requires.trim(),
"requires_icon": if need.requires.trim().is_empty() { "" } else { "key" },
"road_title": "What happens next",
"road_description": stages_sentence(need),
"road_icon": "route",
@@ -1190,6 +1201,17 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
f
};
let mut features = Vec::new();
// What the person must already have or be for this to work at all,
// first of everything: a card that advertises something conditional
// and says so only after the form has taken their time has already
// wasted it.
// Whether the section exists is the plan's to say, never the
// wording's: under a lift every slot holds a token, and a token is
// never empty, so a word-driven condition would conjure a section
// the live page does not have and shift every feature after it.
if !need.requires.trim().is_empty() {
features.push(strip(s(&w["requires_title"]), s(&w["requires_description"]), s(&w["requires_icon"])));
}
if !s(&w["road_description"]).trim().is_empty() {
features.push(strip(s(&w["road_title"]), s(&w["road_description"]), s(&w["road_icon"])));
}
@@ -1718,7 +1740,7 @@ mod tests {
lands_in: "b".into(), handled_by: "g".into(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], requires: String::new(),
collects: vec![], max_fields: 3,
}
}
@@ -1797,6 +1819,24 @@ mod tests {
assert!(!m.contains_key("won"));
}
#[test]
fn a_stated_prerequisite_comes_before_the_form() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: renew\n who: members\n wants: another year\n requires: You need your membership number from last year's receipt.\n lands_in: renewals\n handled_by: office\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: number }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let features = front["alternatives"][1]["features"].as_array().unwrap();
assert_eq!(features[0]["name"], "What you need");
assert_eq!(features[0]["description"], "You need your membership number from last year's receipt.");
assert_eq!(features[1]["name"], "What happens next");
// No prerequisite, no section.
let none: Plan = serde_yaml::from_str(&yaml.replace(" requires: You need your membership number from last year's receipt.\n", "")).unwrap();
let files = render(&none, &fallback_words(&none)).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
assert_eq!(front["alternatives"][1]["features"][0]["name"], "What happens next");
}
#[test]
fn a_case_is_turned_down_only_where_the_plan_says() {
let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]);