From 1ed33b61604a7d9e574b760dc9fd7db16c658043 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 24 Sep 2026 20:42:41 +0200 Subject: [PATCH] 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) --- src/build.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++----- src/plan.rs | 9 +++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/build.rs b/src/build.rs index 4b811c4..a13dbb7 100644 --- a/src/build.rs +++ b/src/build.rs @@ -196,6 +196,13 @@ pub struct PlanNeed { /// start and not after, where they depart instead. #[serde(default)] pub turned_down_from: Vec, + /// 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 { "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"], &[]); diff --git a/src/plan.rs b/src/plan.rs index 1201c20..6f5875b 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -70,6 +70,10 @@ needs: done_when: [, ] # every stage that counts as finished, good or bad success: [] # the finished stages that are a good outcome for the business skippable: [] # stages a case may pass over (papers already complete) + requires: # what the person must ALREADY have or be for this to work: + # a membership, a reference number, the link from a mail, an + # account. Stated on the card above the form. Omit when there + # is none; never invent one turned_down_from: [open, ] # the unfinished stages a case can still be turned down from; # omit for every one. An applicant is declined before they @@ -122,6 +126,11 @@ Rules: - Every need whose form collects an email should tell the person on arrival and on the decisions about them (`mails`); the plan then needs `mail: { from }`. +- Every feature on a card explains itself: a list says what one row is + and what its buttons do, a form says what to have ready. If an + alternative only works for someone who already has something, say so + in `requires` - a condition the reader meets only after filling the + form in has already cost them the filling in. - Needs that land in the same bucket must have the same stages, done_when, skippable, moves_by, watched_by and handled_by. - Every required field is one the business will use to act on the