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. /// start and not after, where they depart instead.
#[serde(default)] #[serde(default)]
pub turned_down_from: Vec<String>, 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 /// Who presses the button that moves a case INTO a stage, where it
/// is not `handled_by`: another group, several groups, or /// is not `handled_by`: another group, several groups, or
/// `submitter` - the person who sent the form, through their own /// `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", "button": "Send",
"encouragement": "", "encouragement": "",
"icon": "", "icon": "",
// Two sections of context the plan already knows: the road // Three sections of context the plan already knows: what
// a case travels, and who is at the other end. The // the person must have for this to work, the road a case
// fallbacks are built from the plan, so they are true // travels, and who is at the other end. The fallbacks are
// before a model has said a word. // 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_title": "What happens next",
"road_description": stages_sentence(need), "road_description": stages_sentence(need),
"road_icon": "route", "road_icon": "route",
@@ -1190,6 +1201,17 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
f f
}; };
let mut features = Vec::new(); 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() { if !s(&w["road_description"]).trim().is_empty() {
features.push(strip(s(&w["road_title"]), s(&w["road_description"]), s(&w["road_icon"]))); 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(), lands_in: "b".into(), handled_by: "g".into(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![], 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, collects: vec![], max_fields: 3,
} }
} }
@@ -1797,6 +1819,24 @@ mod tests {
assert!(!m.contains_key("won")); 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] #[test]
fn a_case_is_turned_down_only_where_the_plan_says() { fn a_case_is_turned_down_only_where_the_plan_says() {
let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]); let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]);
+9
View File
@@ -70,6 +70,10 @@ needs:
done_when: [<end>, <end>] # every stage that counts as finished, good or bad done_when: [<end>, <end>] # every stage that counts as finished, good or bad
success: [<end>] # the finished stages that are a good outcome for the business success: [<end>] # the finished stages that are a good outcome for the business
skippable: [<stage>] # stages a case may pass over (papers already complete) skippable: [<stage>] # stages a case may pass over (papers already complete)
requires: <one sentence> # 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, <stage>] turned_down_from: [open, <stage>]
# the unfinished stages a case can still be turned down from; # the unfinished stages a case can still be turned down from;
# omit for every one. An applicant is declined before they # 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 - Every need whose form collects an email should tell the person on
arrival and on the decisions about them (`mails`); the plan then arrival and on the decisions about them (`mails`); the plan then
needs `mail: { from }`. 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, - Needs that land in the same bucket must have the same stages,
done_when, skippable, moves_by, watched_by and handled_by. done_when, skippable, moves_by, watched_by and handled_by.
- Every required field is one the business will use to act on the - Every required field is one the business will use to act on the