A need may be paused #14

Merged
bl merged 1 commits from paused-needs into main 2026-09-30 18:10:56 +00:00
7 changed files with 56 additions and 13 deletions
+4
View File
@@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file). content repos pin the tag they run (see the end of this file).
## Unreleased
- Trainer: `paused: true` on a need. Its card stays on the front door with what the business does and a sentence saying it is not taking new ones right now, with no form and a button that does not press; nothing is recorded. The lint reports a paused need as a warning ("paused, not open now") and counts it on its own in the summary, as `planned` already did for needs not open yet. Klingenberg Bygg's building applications are paused for capacity.
## 0.6.4 (2026-09-30) ## 0.6.4 (2026-09-30)
- The rate limit is a hundred at once, refilled at ten a second (it was 30, one every two seconds, which a page with a picture strip used up in one load: a few reloads and the stylesheet was refused). The files a page is made of, from portal's own disk, are not counted at all. `PORTAL_RATE_PER_SECOND` replaces `PORTAL_RATE_SECONDS`; no site set it. - The rate limit is a hundred at once, refilled at ten a second (it was 30, one every two seconds, which a page with a picture strip used up in one load: a few reloads and the stylesheet was refused). The files a page is made of, from portal's own disk, are not counted at all. `PORTAL_RATE_PER_SECOND` replaces `PORTAL_RATE_SECONDS`; no site set it.
+2
View File
@@ -100,7 +100,9 @@ needs:
- { name: oppstart, type: date, optional: true } - { name: oppstart, type: date, optional: true }
- { name: skisser, type: file, optional: true } - { name: skisser, type: file, optional: true }
# På pause: fullt hos oss. Ta linjen ut for å åpne igjen.
- id: soknad - id: soknad
paused: true
who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak
wants: en godkjent byggesøknad, med ansvarsrett der det kreves wants: en godkjent byggesøknad, med ansvarsrett der det kreves
lands_in: soknader lands_in: soknader
+2 -1
View File
@@ -109,7 +109,8 @@
"telefon": "Telefonnummer", "telefon": "Telefonnummer",
"adresse": "Adresse", "adresse": "Adresse",
"tiltak": "Tilbygg, ombygging, nybygg…" "tiltak": "Tilbygg, ombygging, nybygg…"
} },
"paused": "Akkurat nå har vi ikke kapasitet til nye søknadsoppdrag; vi åpner igjen så snart vi kan."
} }
}, },
"who": { "who": {
+5 -3
View File
@@ -180,11 +180,13 @@ fn check_needs(
} }
} }
if failed == 0 { if failed == 0 {
let planned = file.needs.iter().filter(|n| n.planned).count(); let planned = file.needs.iter().filter(|n| n.planned && !n.paused).count();
let paused = file.needs.iter().filter(|n| n.paused).count();
println!( println!(
"OK: {} need(s) structurally met, {} planned, {} persona(s)", "OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
file.needs.len() - planned, file.needs.len() - planned - paused,
planned, planned,
paused,
file.personas.len() file.personas.len()
); );
} }
+9 -5
View File
@@ -78,6 +78,10 @@ pub struct Need {
/// as a warning, so the gap stays visible without failing CI. /// as a warning, so the gap stays visible without failing CI.
#[serde(default)] #[serde(default)]
pub planned: bool, pub planned: bool,
/// Open before and closed for now (full up, out of season): its
/// card is on the page with no form. Reports like `planned`.
#[serde(default)]
pub paused: bool,
/// Each state of the bucket in plain words, in the site's /// Each state of the bucket in plain words, in the site's
/// language, for the scorer: state ids are ASCII, and a Norwegian /// language, for the scorer: state ids are ASCII, and a Norwegian
/// `ikke_lost` reads as English to a model. /// `ikke_lost` reads as English to a model.
@@ -305,11 +309,11 @@ pub fn check(
let mut report = |level: Level, message: String| { let mut report = |level: Level, message: String| {
findings.push(Finding { findings.push(Finding {
need: need.id.clone(), need: need.id.clone(),
level: if need.planned { Level::Warn } else { level }, level: if need.planned || need.paused { Level::Warn } else { level },
message: if need.planned && level == Level::Fail { message: match (need.paused, need.planned, level == Level::Fail) {
format!("planned, not met yet: {message}") (true, _, true) => format!("paused, not open now: {message}"),
} else { (false, true, true) => format!("planned, not met yet: {message}"),
message _ => message,
}, },
}) })
}; };
+28 -3
View File
@@ -425,6 +425,14 @@ pub struct PlanNeed {
/// picture. /// picture.
#[serde(default)] #[serde(default)]
pub presents: bool, pub presents: bool,
/// Not taking new cases for now: full up, a season closed. The
/// card stays where it is, saying what the business does and that
/// it is paused, with no form to fill in and a button that does not
/// press; the server refuses a submission. Remove the line to open
/// again - the desk, the stages and every case under way are
/// untouched either way.
#[serde(default)]
pub paused: bool,
pub collects: Vec<Field>, pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the /// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask. /// business said about them, not what the form happens to ask.
@@ -1082,6 +1090,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"button": "Send", "button": "Send",
"encouragement": "", "encouragement": "",
"icon": "", "icon": "",
// Said after the description, on a paused need's card only.
"paused": if need.paused { loc(lang, "We are not taking new ones right now, and open again as soon as we can.", "Akkurat nå tar vi ikke imot nye; vi åpner igjen så snart vi kan.") } else { String::new() },
// Three sections of context the plan already knows: what // Three sections of context the plan already knows: what
// the person must have for this to work, the road a case // the person must have for this to work, the road a case
// travels, and who is at the other end. The fallbacks are // travels, and who is at the other end. The fallbacks are
@@ -1957,15 +1967,29 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
if !s(&w["who_description"]).trim().is_empty() { if !s(&w["who_description"]).trim().is_empty() {
features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"]))); features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"])));
} }
// A paused need asks for nothing: a form nobody can send has
// already wasted the time it took to fill in.
if !need.paused {
features.push(feature); features.push(feature);
}
let description = if need.paused {
text_map(&w["description"], |d| format!("{} {}", d.trim(), s(&w["paused"]).trim()).trim().to_string())
} else {
text(&w["description"])
};
let mut alt = json!({ let mut alt = json!({
"name": text(&w["heading"]), "name": text(&w["heading"]),
"description": text(&w["description"]), "description": description,
"action": action, "action": action,
"consequence": button(&w["button"]), "consequence": button(&w["button"]),
"record_as": need.lands_in, "record_as": need.lands_in,
"features": features, "features": features,
}); });
// Paused, it records nothing and says where nothing goes.
if need.paused {
alt["disabled"] = json!(true);
alt.as_object_mut().map(|a| a.remove("record_as"));
}
if !s(&w["encouragement"]).trim().is_empty() { if !s(&w["encouragement"]).trim().is_empty() {
alt["encouragements"] = button(&w["encouragement"]); alt["encouragements"] = button(&w["encouragement"]);
} }
@@ -2452,6 +2476,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"stage_words": all_stages(n).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); } if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
if n.paused { need["paused"] = json!(true); }
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect(); let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
if !others.is_empty() { need["also_moved_by"] = json!(others); } if !others.is_empty() { need["also_moved_by"] = json!(others); }
need need
@@ -2525,7 +2550,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
context.extend( context.extend(
plan.needs plan.needs
.iter() .iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))), .map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, if n.paused { " (PAUSED: not taking new cases right now; its card shows no form and its button does not press, and `paused` is the sentence after its description that says so)" } else { "" }, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
); );
format!( format!(
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.", "You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.",
@@ -2682,7 +2707,7 @@ mod tests {
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![], 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(), followed_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(), followed_by: vec![],
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false,
collects: vec![], max_fields: 3, collects: vec![], max_fields: 3,
} }
} }
+5
View File
@@ -191,6 +191,11 @@ rest. Each is one line in the plan, and the builder does the wiring:
of them, as cards: the people who work there, entered from inside of them, as cards: the people who work there, entered from inside
(`enters_as`), each with a `picture` field that shows as their (`enters_as`), each with a `picture` field that shows as their
portrait. portrait.
- **Pause what you cannot take on.** `paused: true` on a need keeps
its card on the page, saying what the business does and that it is
not taking new ones right now, with no form and a button that does
not press. Take the line out to open again; the desk and every case
under way are untouched.
- **Show the work.** `slideshow` on the plan is portal's own block, - **Show the work.** `slideshow` on the plan is portal's own block,
carried onto the front door as written: a strip of pictures under carried onto the front door as written: a strip of pictures under
the question, each with its caption. the question, each with its caption.