diff --git a/README.md b/README.md index f7b6aac..4d345bb 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,12 @@ the business's own words and held-out personas. body (`aggregates.yaml` in the words map); the fallback says what happened and, where the submitter has a move from that stage, the link to make it. +- `stages_text: { : }` on a need: each stage in the + plan's language, where the ASCII id will not do (`ikke_lost` reads + as English). Used by the fallback mails, the "what happens next" + line, the desk buttons' fallback labels, and written into needs.yaml + as `stage_words` so the desk score describes an outcome in the + site's own words. With them, the vel's desks score 26 of 26. - `attended_by:` on a need: who moves cases that no desk button moves, an automation listening on the bucket's events, written into aggregates.yaml as portal's own annotation for the reader. diff --git a/plans/tomtervel.yaml b/plans/tomtervel.yaml index e28aad5..4f60f99 100644 --- a/plans/tomtervel.yaml +++ b/plans/tomtervel.yaml @@ -70,6 +70,7 @@ needs: door: medlem handled_by: kasserer stages: [open, betalt, utmeldt] + stages_text: { open: "mottatt", betalt: "betalt", utmeldt: "meldt ut" } done_when: [betalt, utmeldt] success: [betalt] # Medlemmet melder seg ut selv, gjennom sin egen lenke. @@ -89,6 +90,7 @@ needs: door: medlem handled_by: kasserer stages: [open, oppdatert] + stages_text: { open: "mottatt", oppdatert: "oppdatert" } done_when: [oppdatert] success: [oppdatert] max_fields: 3 @@ -104,6 +106,7 @@ needs: door: melde-fra handled_by: trafikk stages: [open, tatt_opp, ordnet, avvist] + stages_text: { open: "mottatt", tatt_opp: "tatt opp med kommunen", ordnet: "ordnet", avvist: "avvist" } done_when: [ordnet, avvist] success: [ordnet] # Alle kan se hva som er meldt og hvor det står; e-posten vises ikke. @@ -121,6 +124,7 @@ needs: door: melde-fra handled_by: lekeplasser stages: [open, tatt_tak_i, utbedret] + stages_text: { open: "mottatt", tatt_tak_i: "tatt tak i", utbedret: "utbedret" } done_when: [utbedret] success: [utbedret] watched_by: [public] @@ -137,6 +141,7 @@ needs: handled_by: horingsinstans watched_by: [styret] stages: [open, tatt_med, ikke_tatt_med] + stages_text: { open: "mottatt", tatt_med: "tatt med i høringssvaret", ikke_tatt_med: "ikke tatt med" } done_when: [tatt_med, ikke_tatt_med] success: [tatt_med] max_fields: 3 @@ -152,6 +157,7 @@ needs: door: bidra handled_by: arrangementer stages: [open, kontaktet, satt_opp, takket_nei] + stages_text: { open: "mottatt", kontaktet: "kontaktet", satt_opp: "satt opp", takket_nei: "takket nei" } done_when: [satt_opp, takket_nei] success: [satt_opp] max_fields: 3 @@ -167,6 +173,7 @@ needs: door: bidra handled_by: styret stages: [open, plassert, avslatt] + stages_text: { open: "mottatt", plassert: "plassert i en komité", avslatt: "avslått" } done_when: [plassert, avslatt] success: [plassert] max_fields: 3 @@ -185,6 +192,7 @@ needs: 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" } done_when: [ordnet, ikke_lost] success: [ordnet] moves_by: { ordnet: [submitter, nabohjelp] } @@ -204,6 +212,7 @@ needs: door: naboer handled_by: nabohjelp stages: [open, registrert, trukket] + stages_text: { open: "mottatt", registrert: "registrert som hjelper", trukket: "trukket" } done_when: [registrert, trukket] success: [registrert] moves_by: { trukket: [submitter, nabohjelp] } @@ -222,6 +231,7 @@ needs: door: naboer handled_by: styret stages: [open, lagt_ut, tatt_ned] + stages_text: { open: "mottatt", lagt_ut: "lagt ut", tatt_ned: "tatt ned" } done_when: [lagt_ut, tatt_ned] success: [lagt_ut] # Den som la det ut kan ta det ned selv. Tavla er offentlig: det er @@ -243,6 +253,7 @@ needs: lands_in: utlegg handled_by: kasserer stages: [open, godkjent, utbetalt, avvist] + stages_text: { open: "mottatt", godkjent: "godkjent", utbetalt: "utbetalt", avvist: "avvist" } done_when: [utbetalt, avvist] success: [utbetalt] max_fields: 3 diff --git a/src/build.rs b/src/build.rs index 54ee171..c974cd2 100644 --- a/src/build.rs +++ b/src/build.rs @@ -190,6 +190,13 @@ pub struct PlanNeed { /// its desk. #[serde(default)] pub events: BTreeMap, + /// Each stage in plain words, in the plan's language, where the + /// id will not do: ids are ASCII, so a Norwegian `ikke_lost` reads + /// as English in a mail, a road line or a desk score. Used by the + /// fallback mails, the "what happens next" line, the desk + /// buttons, and written into needs.yaml for the scorer. + #[serde(default)] + pub stages_text: BTreeMap, /// Who moves cases that no desk button moves - an automation /// listening on the bucket's events. Written into aggregates.yaml /// for the reader, as portal's `attended_by` annotation. @@ -364,6 +371,14 @@ impl Plan { bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from")); } } + for (state, text) in &need.stages_text { + if !need.stages.contains(state) { + bail!(at(&format!("stages_text names {state:?}, which is not one of its stages"))); + } + if text.trim().is_empty() { + bail!(at(&format!("stages_text for {state:?} is empty"))); + } + } for (state, event) in &need.events { if !need.stages.contains(state) { bail!(at(&format!("events names {state:?}, which is not one of its stages"))); @@ -425,6 +440,7 @@ impl Plan { || other.events != first.events || other.attended_by != first.attended_by || other.mails != first.mails + || other.stages_text != first.stages_text { bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails and who moves and watches it", first.id, other.id); } @@ -521,9 +537,14 @@ pub fn event_for(need: &PlanNeed, stage: &str) -> String { /// The stages a case goes through, as one sentence: "It is received, /// 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()) +} + fn stages_sentence(need: &PlanNeed) -> String { - let line: Vec = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| humanize(s).to_lowercase()).collect(); - let ends: Vec = need.done_when.iter().map(|s| humanize(s).to_lowercase()).collect(); + let line: Vec = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| stage_text(need, s)).collect(); + let ends: Vec = need.done_when.iter().map(|s| stage_text(need, s)).collect(); let mut out = String::from("It is "); out.push_str(&line.join(", then ")); if ends.len() == 1 { @@ -534,6 +555,14 @@ fn stages_sentence(need: &PlanNeed) -> String { out } +fn capitalize(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + fn humanize(id: &str) -> String { let mut words = id.replace(['_', '-'], " "); if let Some(first) = words.get_mut(0..1) { @@ -679,7 +708,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { // Only the buttons this group presses; a watcher gets none. let moves: Map = moves_of(needs[0], &group) .into_iter() - .map(|(from, to)| (format!("{from}->{to}"), Value::String(humanize(&to)))) + .map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to))))) .collect(); (bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves })) }) @@ -749,7 +778,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { }; let mut per_stage: Map = Map::new(); for (stage, to) in &need.mails { - let stage_word = humanize(stage).to_lowercase(); + let stage_word = stage_text(need, stage); let what = if stage == &need.stages[0] { format!("{} {}.", t.received_what_you_sent_about, need.wants) } else { @@ -1440,6 +1469,7 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result>(), }); 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(); @@ -1612,7 +1642,7 @@ mod tests { lands_in: "b".into(), handled_by: "g".into(), 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(), + events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), collects: vec![], max_fields: 3, } } diff --git a/src/plan.rs b/src/plan.rs index 5b7b12f..f90ee3a 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -80,6 +80,9 @@ needs: # who hears when a case enters a stage: `submitter` (the form # must then collect a required `email`) or one address. Tell the # person on arrival and on every decision that is about them. + stages_text: { open: , : } + # each stage in the intake's language, where the ASCII id + # would read wrong to a person: "ikke_lost" is "ikke løst" events: { open: applied } # ONLY when rebuilding a live site: the event names its # aggregates.yaml already has, so its records keep replaying attended_by: > # ONLY for a stage no desk button moves: the automation that does diff --git a/src/rate.rs b/src/rate.rs index 1a529b2..7df5a70 100644 --- a/src/rate.rs +++ b/src/rate.rs @@ -187,8 +187,10 @@ fn s(v: &Value) -> String { /// the button for it. The outcome is put in plain words from the /// state's own name, never from the label, and the buttons are listed /// in a seeded shuffle. -fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize) -> anyhow::Result { - let plain = |id: &str| id.replace(['_', '-'], " "); +fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result { + // The state in the plan's own words when the spec carries them, + // else the id made readable. + let plain = |id: &str| words[id].as_str().map(str::to_string).unwrap_or_else(|| id.replace(['_', '-'], " ")); let mut order: Vec<&str> = labels.to_vec(); // A fixed rotation per move: the right answer is not always first. let by = seed % order.len().max(1); @@ -319,7 +321,7 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { continue; } asked += 1; - let picked = button_pick(&cmd, group, bucket, from, to, &labels, i)?; + let picked = button_pick(&cmd, group, bucket, from, to, &labels, i, &model["buckets"][bucket]["words"])?; if picked == *label { right += 1; } else {