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:
co-authored by
Claude Fable 5.1
parent
3dabcf7c58
commit
1025dd7718
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user