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
+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();