From 9368086d81487af65aa8e73e1329615ced7dbc97 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 20:09:14 +0200 Subject: [PATCH] A need may be paused: its card stays, with no form and a button that does not press Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- CHANGELOG.md | 4 ++++ plans/klingenbergbygg.yaml | 2 ++ plans/words/klingenbergbygg.json | 3 ++- src/lint/check.rs | 8 +++++--- src/lint/needs.rs | 14 +++++++++----- src/trainer/build.rs | 33 ++++++++++++++++++++++++++++---- src/trainer/plan.rs | 5 +++++ 7 files changed, 56 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b51023..86467b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` tag; the 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) - 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. diff --git a/plans/klingenbergbygg.yaml b/plans/klingenbergbygg.yaml index 67783ec..875051c 100644 --- a/plans/klingenbergbygg.yaml +++ b/plans/klingenbergbygg.yaml @@ -100,7 +100,9 @@ needs: - { name: oppstart, type: date, optional: true } - { name: skisser, type: file, optional: true } + # På pause: fullt hos oss. Ta linjen ut for å åpne igjen. - id: soknad + paused: true 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 lands_in: soknader diff --git a/plans/words/klingenbergbygg.json b/plans/words/klingenbergbygg.json index a9eb73b..9392890 100644 --- a/plans/words/klingenbergbygg.json +++ b/plans/words/klingenbergbygg.json @@ -109,7 +109,8 @@ "telefon": "Telefonnummer", "adresse": "Adresse", "tiltak": "Tilbygg, ombygging, nybygg…" - } + }, + "paused": "Akkurat nå har vi ikke kapasitet til nye søknadsoppdrag; vi åpner igjen så snart vi kan." } }, "who": { diff --git a/src/lint/check.rs b/src/lint/check.rs index 655a9cb..31e90f2 100644 --- a/src/lint/check.rs +++ b/src/lint/check.rs @@ -180,11 +180,13 @@ fn check_needs( } } 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!( - "OK: {} need(s) structurally met, {} planned, {} persona(s)", - file.needs.len() - planned, + "OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)", + file.needs.len() - planned - paused, planned, + paused, file.personas.len() ); } diff --git a/src/lint/needs.rs b/src/lint/needs.rs index 893bf4c..d5aacbd 100644 --- a/src/lint/needs.rs +++ b/src/lint/needs.rs @@ -78,6 +78,10 @@ pub struct Need { /// as a warning, so the gap stays visible without failing CI. #[serde(default)] 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 /// language, for the scorer: state ids are ASCII, and a Norwegian /// `ikke_lost` reads as English to a model. @@ -305,11 +309,11 @@ pub fn check( let mut report = |level: Level, message: String| { findings.push(Finding { need: need.id.clone(), - level: if need.planned { Level::Warn } else { level }, - message: if need.planned && level == Level::Fail { - format!("planned, not met yet: {message}") - } else { - message + level: if need.planned || need.paused { Level::Warn } else { level }, + message: match (need.paused, need.planned, level == Level::Fail) { + (true, _, true) => format!("paused, not open now: {message}"), + (false, true, true) => format!("planned, not met yet: {message}"), + _ => message, }, }) }; diff --git a/src/trainer/build.rs b/src/trainer/build.rs index c49c6ab..916b623 100644 --- a/src/trainer/build.rs +++ b/src/trainer/build.rs @@ -425,6 +425,14 @@ pub struct PlanNeed { /// picture. #[serde(default)] 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, /// How many required fields this person will tolerate: what the /// business said about them, not what the form happens to ask. @@ -1082,6 +1090,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { "button": "Send", "encouragement": "", "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 // the person must have for this to work, the road a case // 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() { features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"]))); } - features.push(feature); + // 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); + } + 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!({ "name": text(&w["heading"]), - "description": text(&w["description"]), + "description": description, "action": action, "consequence": button(&w["button"]), "record_as": need.lands_in, "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() { alt["encouragements"] = button(&w["encouragement"]); } @@ -2452,6 +2476,7 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result>(), }); 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(); if !others.is_empty() { need["also_moved_by"] = json!(others); } need @@ -2525,7 +2550,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) - context.extend( plan.needs .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::>().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::>().join(", "))), ); 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.", @@ -2682,7 +2707,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(), 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, } } diff --git a/src/trainer/plan.rs b/src/trainer/plan.rs index adae259..afcc679 100644 --- a/src/trainer/plan.rs +++ b/src/trainer/plan.rs @@ -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 (`enters_as`), each with a `picture` field that shows as their 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, carried onto the front door as written: a strip of pictures under the question, each with its caption. -- 2.54.0