A card of one's own: one_per_person, prefilled, replaced on send; no double sends; refusals are shown
Test / test (pull_request) Successful in 1m12s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 20:39:01 +02:00
co-authored by Claude Opus 5.5
parent cae116b50c
commit 809a6bdfe8
12 changed files with 269 additions and 24 deletions
+15 -2
View File
@@ -433,6 +433,13 @@ pub struct PlanNeed {
/// untouched either way.
#[serde(default)]
pub paused: bool,
/// Each person in the group that enters it keeps one record: their
/// own card, which they open filled in and send again to change.
/// For what people say about themselves - who they are on the
/// front page. Needs `enters_as`, so there is someone signed in to
/// own it.
#[serde(default)]
pub one_per_person: bool,
pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask.
@@ -756,6 +763,9 @@ impl Plan {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
}
}
if need.one_per_person && need.enters_as.is_none() {
bail!(at("one_per_person keeps a card for each signed-in person, and the need has no enters_as to sign them in"));
}
if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by"));
}
@@ -1985,6 +1995,9 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
"record_as": need.lands_in,
"features": features,
});
if need.one_per_person {
alt["one_per_person"] = json!(true);
}
// Paused, it records nothing and says where nothing goes.
if need.paused {
alt["disabled"] = json!(true);
@@ -2550,7 +2563,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
context.extend(
plan.needs
.iter()
.map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, if n.paused { " (PAUSED: not taking new cases right now; its card shows no form and its button does not press, and `paused` is the sentence after its description that says so)" } else { "" }, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
.map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, match (n.paused, n.one_per_person) { (true, _) => " (PAUSED: not taking new cases right now; its card shows no form and its button does not press, and `paused` is the sentence after its description that says so)", (_, true) => " (ONE EACH: every signed-in person keeps one record here, their own; the form opens filled in with it and sending saves the change, so it speaks to them about their own and its button saves)", _ => "" }, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
);
format!(
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.",
@@ -2707,7 +2720,7 @@ mod tests {
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![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false,
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false, one_per_person: false,
collects: vec![], max_fields: 3,
}
}
+5
View File
@@ -191,6 +191,11 @@ rest. Each is one line in the plan, and the builder does the wiring:
of them, as cards: the people who work there, entered from inside
(`enters_as`), each with a `picture` field that shows as their
portrait.
- **Let people keep their own.** `one_per_person: true` on a need
entered from inside gives each signed-in person one record there:
their own, which the form opens with and which sending again
replaces - a card on the front page they edit themselves. A picture
left unchosen stays as it was.
- **Pause what you cannot take on.** `paused: true` on a need keeps
its card on the page, saying what the business does and that it is
not taking new ones right now, with no form and a button that does