build: one page holds every need that answers a record; runs: the kommune planned again by Opus

Two needs about the same record wrote the same page, and the last won.
The record's page now holds one form per answering need, under
words["forms"][need].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 08:58:55 +02:00
co-authored by Claude Fable 5.1
parent 9776dc1808
commit 3dabcf7c58
58 changed files with 8713 additions and 37 deletions
+25 -13
View File
@@ -934,17 +934,19 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
}),
);
}
// A page per record of a public need, where another need answers
// it: the record's own words come from the record, so the page
// only has to ask and hold the form.
for need in plan.needs.iter().filter(|n| n.about.is_some()) {
let parent = plan.parent_of(need).expect("validated");
// A page per record of a public need, where other needs answer it
// - an objection and an appeal both belong to the application. The
// record's own words come from the record, so the page only has to
// ask and hold the forms, one per answering need.
for parent in &plan.needs {
let answers = plan.answers_to(parent);
let Some(first) = answers.first() else { continue };
words.insert(
answer_page_path(&parent.lands_in),
json!({
"question": format!("{}?", capitalize(&need.wants)),
"description": format!("For {}.", need.who),
"form": form(need),
"question": format!("{}?", capitalize(&first.wants)),
"description": format!("For {}.", answers.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(", or ")),
"forms": answers.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}),
);
}
@@ -1781,8 +1783,11 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
// A page per record of a board, holding the form that answers it.
// Dynamic: portal serves `/status/<bucket>/<id>` from it, and the
// form's record field carries that id.
for need in plan.needs.iter().filter(|n| n.about.is_some()) {
let parent = plan.parent_of(need).expect("validated");
for parent in &plan.needs {
let answers = plan.answers_to(parent);
if answers.is_empty() {
continue;
}
let path = answer_page_path(&parent.lands_in);
let w = &words[&path];
files.insert(
@@ -1791,7 +1796,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"name": s(&w["question"]),
"description": s(&w["description"]),
"responsible": responsible,
"alternatives": [form_alternative(plan, need, &w["form"], &public_followup(need))],
"alternatives": answers.iter().map(|n| form_alternative(plan, n, &w["forms"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
}),
);
}
@@ -2004,7 +2009,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
(None, Some(door), _) => ("/".to_string(), s(&front["doors"][door]["heading"]).to_string(), 2),
// An answer enters on the record's own page, which
// the board's row links to.
(None, None, Some(parent)) => (answer_page(&parent.lands_in), s(&words[&answer_page_path(&parent.lands_in)]["form"]["heading"]).to_string(), 1),
(None, None, Some(parent)) => (answer_page(&parent.lands_in), s(&words[&answer_page_path(&parent.lands_in)]["forms"][&n.id]["heading"]).to_string(), 1),
(None, None, None) => ("/".to_string(), s(&front["needs"][&n.id]["heading"]).to_string(), 1),
};
let mut need = json!({
@@ -2052,7 +2057,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
} else if path.ends_with("/index.yaml") {
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons."
} else if path.ends_with("/[key].yaml") {
"a page one record of a public list opens to. This is NOT where a notice is posted: the visitor has read somebody else's notice on the board and is answering it, and their answer is mailed straight to the person who posted it. `question` asks the visitor whether they can do what the notice asks; `form` is the form they fill, at most an address and a line. Nobody at a desk passes the answer on, so never say so."
"a page one record of a public list opens to. This is NOT where a notice is posted: the visitor has read somebody else's record on the list (a notice, an application) and is answering it. `question` asks the visitor what they have to say to it; under `forms`, each entry is one kind of answer (an objection, an appeal, an offer to lend), with the form behind it. An answer that mails the record's owner goes straight to them; nobody at a desk passes it on, so never say so."
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
} else {
@@ -2986,6 +2991,13 @@ mod invite_tests {
assert_eq!(alt["action"], "/received/answer");
let reqs = alt["features"].as_array().unwrap().last().unwrap()["requirements"].as_array().unwrap();
assert_eq!(reqs[0]["value"], "{user.email}");
// Two needs about the same record share its page, one form each.
let two: Plan = serde_yaml::from_str(&format!("{BOARD} - id: borrow\n who: en nabo som vil låne det\n wants: å låne det\n about: notice\n lands_in: laan\n handled_by: redaktor\n stages: [open, lest]\n done_when: [lest]\n max_fields: 1\n collects:\n - {{ name: email, type: email }}\n")).unwrap();
two.validate().unwrap();
let files = render(&two, &fallback_words(&two)).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/status/oppslag/[key].yaml"]).unwrap();
let buckets: Vec<&str> = page["alternatives"].as_array().unwrap().iter().map(|a| a["record_as"].as_str().unwrap()).collect();
assert_eq!(buckets, ["svar", "laan"]);
assert_eq!(reqs[1]["type"], "record");
assert_eq!(reqs[1]["of"], "oppslag");
assert_eq!(reqs[1]["value"], "{key}");