Compare commits

...
7 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug cae116b50c Release 0.6.5
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m54s
2026-09-30 20:10:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 d8528ff1f9 Changelog: 0.6.5
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:10:56 +02:00
bl 6c2e0cc299 Merge pull request 'A need may be paused' (#14) from paused-needs into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m10s
2026-09-30 18:10:56 +00:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9368086d81 A need may be paused: its card stays, with no form and a button that does not press
Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 20:09:14 +02:00
Bendik Aagaard Lynghaug e2497941c8 Release 0.6.4
Test / test (push) Successful in 1m9s
Publish release / publish (push) Successful in 1m55s
2026-09-30 19:52:12 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 55fc61c514 Changelog: 0.6.4
docs / deploy (push) Successful in 2s
Test / test (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 19:52:10 +02:00
bl 3b17e6440e Merge pull request 'Rate limit: 100 at once, 10 a second; the page's own files uncounted' (#13) from rate-limit into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m9s
2026-09-30 17:52:09 +00:00
9 changed files with 59 additions and 16 deletions
+5 -1
View File
@@ -4,7 +4,11 @@ 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
content repos pin the tag they run (see the end of this file).
## Unreleased
## 0.6.5 (2026-09-30)
- 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.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.6.3"
version = "0.6.5"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.6.3"
version = "0.6.5"
edition = "2021"
[lib]
+2
View File
@@ -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
+2 -1
View File
@@ -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": {
+5 -3
View File
@@ -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()
);
}
+9 -5
View File
@@ -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,
},
})
};
+29 -4
View File
@@ -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<Field>,
/// 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<String, Value> {
"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<String, Value>) -> anyhow::Result<BT
"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 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::<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!(
"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,
}
}
+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
(`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.