From 1025dd7718707ef07fb6c5528448da1a39aaeb6a Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 11:58:02 +0200 Subject: [PATCH] build: escalate - a case left too long goes up to another desk with the same buttons escalate: { : { days, to: } } on a need adds a 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 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- plans/tomtervel.yaml | 5 +- .../words-board.json | 76 ++++++++ src/build.rs | 162 ++++++++++++++++-- src/plan.rs | 5 + 4 files changed, 230 insertions(+), 18 deletions(-) diff --git a/plans/tomtervel.yaml b/plans/tomtervel.yaml index 199edb6..57a6139 100644 --- a/plans/tomtervel.yaml +++ b/plans/tomtervel.yaml @@ -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] } diff --git a/runs/tomtervel-oppslagstavla-2026-09-30/words-board.json b/runs/tomtervel-oppslagstavla-2026-09-30/words-board.json index 73aabf5..b1b295d 100644 --- a/runs/tomtervel-oppslagstavla-2026-09-30/words-board.json +++ b/runs/tomtervel-oppslagstavla-2026-09-30/words-board.json @@ -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." } } \ No newline at end of file diff --git a/src/build.rs b/src/build.rs index 2248a68..168584e 100644 --- a/src/build.rs +++ b/src/build.rs @@ -283,6 +283,15 @@ pub struct PlanNeed { /// may do only what a person could have done. #[serde(default)] pub deadlines: BTreeMap, + /// 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, /// 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> { } } } + // 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 { + 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 { + 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 { .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) -> anyhow::Result) -> anyhow::Result{to}")]) }) + }).collect::>(), + }); + // 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::>(), - }}], + "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) -> anyhow::Result>(), + "stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::>(), }); 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(); diff --git a/src/plan.rs b/src/plan.rs index b751bb1..e96c069 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -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