build: escalate - a case left too long goes up to another desk with the same buttons

escalate: { <stage>: { days, to: <group> } } on a need adds a stage
<stage>_escalated the clock enters, with the same moves out of it; the
group's desk lists exactly those cases with those buttons, and the
desk that had it keeps it and may send it up itself. Not a mail: a
desk job. The vel's help requests go up to styret after a week.

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 11:58:02 +02:00
co-authored by Claude Fable 5.1
parent 3dabcf7c58
commit 1025dd7718
4 changed files with 230 additions and 18 deletions
+4 -1
View File
@@ -202,10 +202,13 @@ needs:
who: en beboer som ikke får til noe selv for tiden, som snømåking, handling eller en skyss
wants: at en nabo stiller opp
lands_in: hjelpebehov
# Et behov ingen har tatt tak i på en uke går opp til styret: samme
# sak, samme knapper, på styrets pult i tillegg.
escalate: { open: { days: 7, to: styret } }
door: naboer
handled_by: nabohjelp
stages: [open, formidlet, ordnet, ikke_lost]
stages_text: { open: "mottatt", formidlet: "formidlet til en nabo", ordnet: "ordnet", ikke_lost: "ikke løst" }
stages_text: { open: "mottatt", open_escalated: "hos styret, siden ingen tok den på en uke", formidlet: "formidlet til en nabo", ordnet: "ordnet", ikke_lost: "ikke løst" }
done_when: [ordnet, ikke_lost]
success: [ordnet]
moves_by: { ordnet: [submitter, nabohjelp] }
@@ -135,11 +135,34 @@
"name": "Navn",
"email": "E-post"
}
},
"medlemmer": {
"heading": "Legg til en husstand som medlem uten kontingent via siden",
"description": "For en husstand som har betalt på annen måte. De får en konto og plass blant medlemmene, og en engangslenke der de selv velger passord eller passnøkkel.",
"button": "Legg til",
"form_title": "Hvem",
"form_description": "Navnet deres, og adressen kontoen og engangslenken går til.",
"labels": {
"name": "Navn",
"email": "E-post"
}
}
},
"invited": {
"description": "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt.",
"list_note": "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."
},
"buckets": {
"hjelpebehov": {
"heading": "Hjelpebehov ingen har tatt tak i",
"description": "Behov som har ligget en uke uten at nabohjelpsgruppa koblet på noen. Styret finner en nabo, eller sier fra at det ikke lot seg løse.",
"moves": {
"open_escalated->formidlet": "En nabo er koblet på",
"open_escalated->ikke_lost": "Lot seg ikke løse"
},
"list_title": "Behov hos styret",
"list_note": "Hver rad er ett behov som ingen tok på en uke. Knappene fører opp det du nettopp har gjort med det."
}
}
},
"questions/arrangementer/index.yaml": {
@@ -176,6 +199,15 @@
"invited": {
"description": "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt.",
"list_note": "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."
},
"buckets": {
"hjelpebehov": {
"moves": {
"open->open_escalated": "Send til styret",
"open_escalated->formidlet": "Koble til nabo",
"open_escalated->ikke_lost": "Meld ikke løst"
}
}
}
},
"questions/trafikk/index.yaml": {
@@ -201,5 +233,49 @@
}
}
}
},
"questions/medlem/index.yaml": {
"needs": {
"bli-medlem": {
"labels": {
"name": "Navn",
"adresse": "Adresse",
"email": "E-post",
"telefon": "Telefon"
}
}
}
},
"questions/bidra/index.yaml": {
"needs": {
"komite": {
"heading": "Sitt i en komité som betyr noe for deg",
"description": "For medlemmer. Logg inn med velkontoen din, så velger du komité; kontoen fikk du da kontingenten var betalt.",
"button": "Logg inn og meld deg"
}
}
},
"questions/medlemmer/index.yaml": {
"question": "Hva kan du gjøre som medlem?",
"description": "Det som er medlemmenes eget. Du er logget inn med kontoen du fikk da kontingenten var betalt.",
"confirm": "Bekreft",
"links": {
"komite": {
"heading": "Sitt i en komité som betyr noe for deg",
"description": "Velet drives av sju komiteer på fritida, og alle trenger flere hender.",
"button": "Velg komité"
}
}
},
"questions/medlemmer/komite.yaml": {
"question": "Hvilken komité vil du sitte i?",
"page_description": "Styret ser søknaden og finner en plass. Navn og e-post har vi fra medlemskapet ditt.",
"button": "Send søknaden"
},
"questions/medlemmer/komite-sent.yaml": {
"question": "Hva skjer nå?",
"description": "Søknaden er hos styret.",
"next_heading": "Styret finner en plass til deg",
"next_description": "Du hører fra styret når de har plassert deg, som regel før neste styremøte. Spørsmål i mellomtiden går til post@tomtervel.no."
}
}
+145 -17
View File
@@ -283,6 +283,15 @@ pub struct PlanNeed {
/// may do only what a person could have done.
#[serde(default)]
pub deadlines: BTreeMap<String, Deadline>,
/// A stage a case may not sit in unattended: after that many days
/// it goes up to another group. `{ open: { days: 7, to: styret } }`
/// adds a stage `open_escalated`, entered by the clock, with the
/// same moves out of it as `open` has, and a card on styret's desk
/// showing exactly those cases with those buttons. The desk that
/// had it keeps it too, and may press the move itself. A stage can
/// escalate or end on a deadline, not both.
#[serde(default)]
pub escalate: BTreeMap<String, Escalation>,
/// With `watched_by: [public]`, the stages the public list shows;
/// empty shows all. A noticeboard shows what is up, not what was
/// taken down; a register shows what was decided.
@@ -334,6 +343,19 @@ pub struct Deadline {
pub to: String,
}
/// After `days` in a stage, up to group `to` (see `PlanNeed.escalate`).
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Escalation {
pub days: u32,
pub to: String,
}
/// The stage a case escalates into from `stage`.
pub fn escalated_stage(stage: &str) -> String {
format!("{stage}_escalated")
}
/// The name a plan uses in `watched_by` for everyone: a read-only page
/// of the bucket, no login.
pub const PUBLIC: &str = "public";
@@ -534,6 +556,20 @@ impl Plan {
bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)"));
}
}
for (stage, e) in &need.escalate {
if e.days == 0 {
bail!(at(&format!("escalate from {stage:?} must wait at least one day")));
}
if !need.stages.contains(stage) || need.done_when.contains(stage) {
bail!(at(&format!("escalate names {stage:?}, which is not a stage a case waits in")));
}
if !is_id(&e.to) || e.to == need.handled_by {
bail!(at(&format!("escalate from {stage:?} must go up to another group, not {:?}", e.to)));
}
if need.deadlines.contains_key(stage) {
bail!(at(&format!("{stage:?} has both a deadline and an escalation; a stage ends on the clock or goes up on it, not both")));
}
}
for (state, d) in &need.deadlines {
if d.days == 0 {
bail!(at(&format!("deadline on {state:?} must wait at least one day")));
@@ -551,7 +587,7 @@ impl Plan {
}
}
for (state, text) in &need.stages_text {
if !need.stages.contains(state) {
if !all_stages(need).contains(state) {
bail!(at(&format!("stages_text names {state:?}, which is not one of its stages")));
}
if text.trim().is_empty() {
@@ -740,9 +776,34 @@ pub fn machine(need: &PlanNeed) -> BTreeMap<String, Vec<String>> {
}
}
}
// An escalated stage is the stage it came from, seen by one more
// group: the same moves out of it, and one move in, the clock's.
for stage in need.escalate.keys() {
let up = escalated_stage(stage);
let Some(tos) = moves.get(stage).cloned() else { continue };
moves.entry(stage.clone()).or_default().push(up.clone());
moves.insert(up, tos);
}
moves
}
/// Every stage a case can be in: the plan's, and the escalated ones.
pub fn all_stages(need: &PlanNeed) -> Vec<String> {
let mut out = Vec::new();
for stage in &need.stages {
out.push(stage.clone());
if need.escalate.contains_key(stage) {
out.push(escalated_stage(stage));
}
}
out
}
/// The group a stage escalated to, if it is an escalated stage.
fn escalated_to<'a>(need: &'a PlanNeed, stage: &str) -> Option<&'a str> {
need.escalate.iter().find(|(from, _)| escalated_stage(from) == stage).map(|(_, e)| e.to.as_str())
}
/// The event that enters a stage: what the plan says, else the stage's
/// own name, with the first stage entered by `received` (or `submitted`
/// when a stage is itself called received).
@@ -760,7 +821,13 @@ pub fn event_for(need: &PlanNeed, stage: &str) -> String {
/// then surveyed, then quoted, and ends won or lost."
/// A stage in words: what the plan says, else its id made readable.
pub fn stage_text(need: &PlanNeed, stage: &str) -> String {
need.stages_text.get(stage).cloned().unwrap_or_else(|| humanize(stage).to_lowercase())
if let Some(text) = need.stages_text.get(stage) {
return text.clone();
}
match escalated_to(need, stage) {
Some(group) => format!("escalated to {}", humanize(group).to_lowercase()),
None => humanize(stage).to_lowercase(),
}
}
fn stages_sentence(need: &PlanNeed) -> String {
@@ -1248,10 +1315,23 @@ fn moves_of(need: &PlanNeed, who: &str) -> Vec<(String, String)> {
machine(need)
.into_iter()
.flat_map(|(from, tos)| tos.into_iter().map(move |to| (from.clone(), to)))
.filter(|(_, to)| movers(need, to).contains(&who))
.filter(|(from, to)| movers(need, to).contains(&who) || escalated_to(need, from) == Some(who))
.collect()
}
/// The stages of a bucket a group sees only because they escalated to
/// it: its card lists those alone. Empty for a group that has the
/// bucket for its own reasons, whose card lists everything.
fn escalated_states_for(need: &PlanNeed, group: &str) -> Vec<String> {
let own = need.handled_by == group
|| need.watched_by.iter().any(|g| g == group)
|| need.moves_by.values().flatten().any(|m| m == group);
if own {
return Vec::new();
}
need.escalate.iter().filter(|(_, e)| e.to == group).map(|(stage, _)| escalated_stage(stage)).collect()
}
/// A group's desk shows a bucket when it moves something in it, is
/// answerable for it, or watches it.
fn on_desk(need: &PlanNeed, group: &str) -> bool {
@@ -1267,6 +1347,7 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
.flatten()
.chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned())
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
.chain(n.escalate.values().map(|e| e.to.clone()))
})
.collect()
}
@@ -1639,8 +1720,11 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
let mut aggregate = json!({
"bucket": bucket,
"initial": &need.stages[0],
"states": need.stages.iter().map(|st| {
"states": all_stages(need).iter().map(|st| {
let mut state = json!({ "event": event_for(need, st) });
if let Some(e) = need.escalate.get(st) {
state["after"] = json!({ "days": e.days, "to": escalated_stage(st) });
}
if let Some(to) = need.mails.get(st) {
let w = &mail_words[bucket][st];
// `owner` is whoever sent the record this need
@@ -1847,21 +1931,28 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
continue;
}
let bw = &w["buckets"][bucket];
let mut resource = json!({
"source": { "kind": "kv", "bucket": bucket },
"requires_group": group,
// This group's buttons only. The same bucket sits on
// other groups' desks with theirs; a watcher's list
// is empty, which portal renders read-only.
"transitions": moves_of(needs[0], &group).into_iter().map(|(from, to)| {
json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })
}).collect::<Vec<_>>(),
});
// A group that has the bucket only because cases escalate
// to it sees those cases and no others.
let escalated = escalated_states_for(needs[0], &group);
if !escalated.is_empty() {
resource["states"] = json!(escalated);
}
alternatives.push(json!({
"name": s(&bw["heading"]),
"description": s(&bw["description"]),
"action": format!("/{group}"),
"consequence": [s(&w["confirm"])],
"features": [{ "name": s(&bw["list_title"]), "description": s(&bw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": bucket },
"requires_group": group,
// This group's buttons only. The same bucket sits on
// other groups' desks with theirs; a watcher's list
// is empty, which portal renders read-only.
"transitions": moves_of(needs[0], &group).into_iter().map(|(from, to)| {
json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })
}).collect::<Vec<_>>(),
}}],
"features": [{ "name": s(&bw["list_title"]), "description": s(&bw["list_note"]), "resource": resource }],
}));
}
for need in plan.needs.iter().filter(|n| n.enters_as.as_deref() == Some(group.as_str())) {
@@ -2015,9 +2106,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
let mut need = json!({
"id": n.id, "who": n.who, "wants": n.wants, "enters": enters, "via": heading,
"lands_in": n.lands_in, "handled_by": n.handled_by,
"stages": n.stages, "done_when": n.done_when, "success": n.success,
"stages": all_stages(n), "done_when": n.done_when, "success": n.success,
"max_steps": steps, "max_fields": n.max_fields,
"stage_words": n.stages.iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
"stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
});
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
@@ -2213,7 +2304,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(),
grants: BTreeMap::new(), deadlines: BTreeMap::new(), public_states: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![],
collects: vec![], max_fields: 3,
}
}
@@ -2971,6 +3062,43 @@ mod invite_tests {
assert!(nobody.validate().unwrap_err().to_string().contains("no desk"));
}
#[test]
fn a_case_left_too_long_goes_up_to_another_desk_with_the_same_buttons() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: traffic\n stages: [open, fixed]\n", " handled_by: traffic\n escalate: { open: { days: 7, to: board } }\n stages: [open, fixed]\n")).unwrap();
plan.validate().unwrap();
// The machine: the clock's move in, the same moves out.
let need = plan.needs.iter().find(|n| n.id == "report").unwrap();
let m = machine(need);
assert_eq!(m["open"], ["fixed", "open_escalated"]);
assert_eq!(m["open_escalated"], ["fixed"]);
assert_eq!(all_stages(need), ["open", "open_escalated", "fixed"]);
let files = render(&plan, &fallback_words(&plan)).unwrap();
// The clock moves it up after seven days.
let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap();
let hazards = agg["aggregates"].as_array().unwrap().iter().find(|a| a["bucket"] == "hazards").unwrap();
assert_eq!(hazards["states"]["open"]["after"], json!({ "days": 7, "to": "open_escalated" }));
assert!(hazards["states"]["open_escalated"].is_object());
// The board's desk lists the escalated cases only, and may finish them.
let board: Value = serde_yaml::from_str(&files["questions/board/index.yaml"]).unwrap();
let card = board["alternatives"].as_array().unwrap().iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "hazards").unwrap();
let resource = &card["features"][0]["resource"];
assert_eq!(resource["states"], json!(["open_escalated"]));
assert_eq!(resource["transitions"][0]["from"], "open_escalated");
assert_eq!(resource["transitions"][0]["to"], "fixed");
// Traffic's own desk still has the case, with everything, and may
// send it up itself.
let traffic: Value = serde_yaml::from_str(&files["questions/traffic/index.yaml"]).unwrap();
let card = traffic["alternatives"].as_array().unwrap().iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "hazards").unwrap();
let tos: Vec<&str> = card["features"][0]["resource"]["transitions"].as_array().unwrap().iter().map(|t| t["to"].as_str().unwrap()).collect();
assert!(tos.contains(&"open_escalated") && tos.contains(&"fixed"), "{tos:?}");
assert!(card["features"][0]["resource"].get("states").is_none());
// The spec knows the stage, in words.
assert!(files["needs.yaml"].contains("open_escalated: escalated to board"));
// A stage cannot both end and go up on the clock.
let both: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: traffic\n stages: [open, fixed]\n", " handled_by: traffic\n escalate: { open: { days: 7, to: board } }\n deadlines: { open: { days: 30, to: fixed } }\n stages: [open, fixed]\n")).unwrap();
assert!(both.validate().unwrap_err().to_string().contains("not both"));
}
#[test]
fn a_need_about_another_is_answered_from_the_boards_own_rows() {
let plan: Plan = serde_yaml::from_str(BOARD).unwrap();
+5
View File
@@ -153,6 +153,11 @@ rest. Each is one line in the plan, and the builder does the wiring:
moment the case gets there; `deadlines` lets a stage end on its own
(a notice that expires, a request nobody took up); `invites_by` lets
a desk bring a person in without anyone touching an admin tool.
- **Let nothing wait unseen.** `escalate: { open: { days: 7, to: styret } }`
sends a case nobody has moved in a week up to another group's desk,
where it sits with the same buttons until someone finishes it; the
desk that had it keeps it too, and may send it up itself. A desk job,
not a mail: the group sees it when they sit down.
- **Let one record answer another.** A need `about` another need is
one answer per record of it, from that record's own row on the
public list: a neighbour saying "I can lend you that" to a notice, a