A card of one's own; no double sends; refusals shown #15

Merged
bl merged 1 commits from own-card into main 2026-09-30 18:39:09 +00:00
12 changed files with 269 additions and 24 deletions
+7
View File
@@ -4,6 +4,13 @@ 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
- `one_per_person: true` on an alternative: each signed-in person keeps one record there, their own. The form opens filled in with it, a file left unchosen keeps the one already there (shown beside the picker), and sending again replaces the card instead of adding one. Two sends at once settle on the newest. Trainer: `one_per_person` on a need entered from inside; Klingenberg Bygg's people each edit their own card on the front page.
- Send is disabled from the moment it is pressed. Files upload before the form is sent, and a second press in that time used to send it twice.
- A failed upload stops the send and says so. The form used to go without the file, which made a record that looked complete and was not.
- A refused send says why, under its button. It used to say nothing.
## 0.6.5 (2026-09-30) ## 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. - 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.
+4 -3
View File
@@ -145,12 +145,13 @@ needs:
- { name: hva } - { name: hva }
- { name: dokument, type: file } - { name: dokument, type: file }
# Menneskene: kontoret legger inn hver ansatt med navn, rolle, # Menneskene: hver ansatt har sitt eget kort på forsiden, med rolle,
# erfaring og portrett, og forsiden viser dem alle. # erfaring og portrett, og retter det selv. Kontoret kan ta et kort ned.
- id: ansatt - id: ansatt
who: kontoret, når noen begynner hos oss eller skal ha nytt portrett who: en av oss på kontoret, som vil vise hvem hen er og hva hen kan
wants: at den som vurderer oss ser hvem som gjør jobben wants: at den som vurderer oss ser hvem som gjør jobben
enters_as: kontor enters_as: kontor
one_per_person: true
lands_in: ansatte lands_in: ansatte
handled_by: kontor handled_by: kontor
watched_by: [public] watched_by: [public]
+18 -18
View File
@@ -258,7 +258,7 @@
}, },
"ansatte": { "ansatte": {
"heading": "Ansatte på forsiden", "heading": "Ansatte på forsiden",
"description": "Menneskene slik besøkende ser dem.", "description": "Menneskene slik besøkende ser dem. Hver retter sitt eget kort; herfra kan et kort tas ned.",
"moves": { "moves": {
"open->sluttet": "Tatt av forsiden" "open->sluttet": "Tatt av forsiden"
}, },
@@ -273,8 +273,8 @@
"button": "Ny leveranse" "button": "Ny leveranse"
}, },
"ansatt": { "ansatt": {
"heading": "Legg inn en ansatt", "heading": "Ditt kort på forsiden",
"description": "Navn, rolle, erfaring og portrett, rett ut på forsiden.", "description": "Rolle, erfaring og portrett, slik besøkende ser deg.",
"button": "Åpne" "button": "Åpne"
} }
}, },
@@ -471,10 +471,10 @@
} }
}, },
"questions/kontor/ansatt-sent.yaml": { "questions/kontor/ansatt-sent.yaml": {
"question": "Lagt ut", "question": "Kortet ditt er lagret",
"description": "Den ansatte vises nå blant menneskene på forsiden.", "description": "Slik står du nå på forsiden.",
"next_heading": "Skal noen ned igjen?", "next_heading": "Vil du endre noe?",
"next_description": "Trykk «Tatt av forsiden» på raden deres i listen over ansatte på kontoret. Nytt portrett: legg personen inn på nytt, og ta den gamle raden av.", "next_description": "Åpne «Ditt kort på forsiden» igjen: det er fylt ut med det du har, og det du lagrer erstatter det.",
"see_all": { "see_all": {
"heading": "Se alle ansatte", "heading": "Se alle ansatte",
"description": "", "description": "",
@@ -482,21 +482,21 @@
} }
}, },
"questions/kontor/ansatt.yaml": { "questions/kontor/ansatt.yaml": {
"heading": "Legg inn en ansatt", "heading": "Ditt kort på forsiden",
"description": "Navn, rolle, erfaring og portrett. Den ansatte vises på forsiden så snart du sender.", "description": "Slik står du blant menneskene på forsiden. Rett det du vil og lagre; du har ett kort, og det nye erstatter det gamle.",
"button": "Legg ut", "button": "Lagre kortet",
"encouragement": "", "encouragement": "",
"icon": "user-plus", "icon": "id-card",
"requires_title": "", "requires_title": "",
"requires_description": "", "requires_description": "",
"requires_icon": "", "requires_icon": "",
"road_title": "Hva skjer så", "road_title": "Når du lagrer",
"road_description": "Personen vises på forsiden til du tar dem av igjen fra kontoret.", "road_description": "Kortet ditt på forsiden endres med en gang.",
"road_icon": "route", "road_icon": "route",
"who_title": "Hvem ser det", "who_title": "Hvem ser det",
"who_description": "Alle som besøker siden. Portrettet vises som det er, så velg et bilde du er fornøyd med.", "who_description": "Alle som besøker siden. Navnet ditt er det du er logget inn med.",
"who_icon": "users", "who_icon": "users",
"form_title": "Den ansatte", "form_title": "Kortet ditt",
"form_description": "", "form_description": "",
"labels": { "labels": {
"name": "Navn", "name": "Navn",
@@ -508,10 +508,10 @@
"name": "", "name": "",
"rolle": "For eksempel daglig leder, rådgivende ingeniør", "rolle": "For eksempel daglig leder, rådgivende ingeniør",
"erfaring": "Utdanning, fagområder, år i faget, bygg du er stolt av", "erfaring": "Utdanning, fagområder, år i faget, bygg du er stolt av",
"portrett": "Et stående bilde, gjerne tatt i dagslys" "portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har."
}, },
"question": "Hvem skal vises på forsiden?", "question": "Ditt kort på forsiden",
"page_description": "" "page_description": "Det du skriver her, står under portrettet ditt på forsiden."
}, },
"questions/status/ansatte/index.yaml": { "questions/status/ansatte/index.yaml": {
"question": "Hvem består Klingenberg Bygg av?", "question": "Hvem består Klingenberg Bygg av?",
+67
View File
@@ -114,6 +114,73 @@ pub async fn store_answer(
Ok(()) Ok(())
} }
/// Whose a record is, on an alternative that keeps one per person
/// (`one_per_person`): the signed-in person's username, written by the
/// server into the record's fields, never taken from the form.
pub const OWNER_FIELD: &str = "by";
/// Every record in `bucket` that belongs to `username`, newest first.
#[cfg(feature = "ssr")]
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
use futures::TryStreamExt;
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
let keys: Vec<String> = match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => return Vec::new(),
};
let mut own = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
own.push(answer);
}
}
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
own
}
/// A person keeps one card: of their records still in the bucket's
/// first state, only the newest stays in the list, and the rest leave
/// it (the event log keeps them). Run after every store, so two sends
/// racing each other - a double click - both settle on the same one.
#[cfg(feature = "ssr")]
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
let own = own_records(js, bucket, username).await;
let Ok(store) = js.get_key_value(bucket).await else { return };
for older in own.iter().filter(|a| a.state == initial).skip(1) {
if let Err(e) = store.delete(&older.id).await {
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
}
}
}
/// Fills the form of every `one_per_person` alternative with what the
/// signed-in person's own newest record says, so sending it again is
/// editing their card. A file field shows the file already there, at
/// the address a list would open it from, and may be left empty to
/// keep it.
#[cfg(feature = "ssr")]
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
let Some(user) = user else { return };
for alt in &mut question.alternatives {
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
if req.kind == "hidden" {
continue;
}
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
if req.kind == "file" {
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
req.optional = true;
} else {
req.value = Some(value.to_string());
}
}
}
}
/// One selected row's pending transition. `feature_name` is per-item /// One selected row's pending transition. `feature_name` is per-item
/// so a batch can span more than one resource feature on the same /// so a batch can span more than one resource feature on the same
/// alternative. /// alternative.
+83 -1
View File
@@ -1198,9 +1198,11 @@ fn AlternativeCard(
if req.kind == "file" { if req.kind == "file" {
file_refs.entry(req.name.clone()).or_default(); file_refs.entry(req.name.clone()).or_default();
} else if req.kind == "select" { } else if req.kind == "select" {
// Preset by a card of one's own (`answers::fill_own`).
let preset: Vec<String> = req.value.clone().filter(|v| !v.is_empty()).into_iter().collect();
select_field_map select_field_map
.entry(req.name.clone()) .entry(req.name.clone())
.or_insert_with(|| RwSignal::new(Vec::new())); .or_insert_with(|| RwSignal::new(preset));
select_multi.insert(req.name.clone(), req.multiple); select_multi.insert(req.name.clone(), req.multiple);
} else { } else {
// Widget-owned fields hold JSON objects, not strings. // Widget-owned fields hold JSON objects, not strings.
@@ -1238,6 +1240,11 @@ fn AlternativeCard(
let gesture_fields_for_submit = gesture_fields.clone(); let gesture_fields_for_submit = gesture_fields.clone();
let question_id_for_transition = question_id.clone(); let question_id_for_transition = question_id.clone();
let alt_name_for_transition = alternative.name.clone(); let alt_name_for_transition = alternative.name.clone();
// From the moment Send is pressed until the server has answered:
// files upload first, and a second press in that time used to send
// the form twice.
let sending = RwSignal::new(false);
let send_error: RwSignal<Option<String>> = RwSignal::new(None);
let on_submit = move |ev: leptos::ev::SubmitEvent| { let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
@@ -1266,6 +1273,8 @@ fn AlternativeCard(
let question_id_for_submit = question_id_for_submit.clone(); let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone(); let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone(); let parent_hash_for_submit = parent_hash_for_submit.clone();
sending.set(true);
send_error.set(None);
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() { for (name, sig) in field_map_for_submit.iter() {
@@ -1305,8 +1314,14 @@ fn AlternativeCard(
Ok(key) => { Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key)); map.insert(name.clone(), serde_json::Value::String(key));
} }
// A form sent without the file it asked for is a
// record that looks complete and is not: stop, and
// say so.
Err(e) => { Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}"); leptos::logging::error!("upload of {name} failed: {e:?}");
send_error.set(Some(t(&lang().get_untracked(), "upload_failed").to_string()));
sending.set(false);
return;
} }
} }
} }
@@ -1319,6 +1334,7 @@ fn AlternativeCard(
parent_hash: parent_hash_for_submit, parent_hash: parent_hash_for_submit,
responses_json, responses_json,
}); });
sending.set(false);
}); });
}; };
@@ -1512,9 +1528,26 @@ fn AlternativeCard(
if req.kind == "file" { if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new); let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
// On a card of one's own, the file already
// there (see `answers::fill_own`).
let current = req.value.clone().filter(|v| v.starts_with("/attachment/")).map(|href| {
let picture = req.accept.as_deref().is_some_and(|a| a.contains("image/"));
view! {
<span class="field-current">
<span>{move || t(&lang().get(), "current_file")}</span>
{if picture {
view! { <img class="answer-thumb" src=href.clone() alt="" /> }.into_any()
} else {
view! { <a href=href.clone() rel="external">{href.rsplit('/').next().unwrap_or_default().to_string()}</a> }.into_any()
}}
<span class="field-hint">{move || t(&lang().get(), "keep_file")}</span>
</span>
}
});
return view! { return view! {
<label class="field" for=label_for> <label class="field" for=label_for>
{label_text} {label_text}
{current}
<input <input
id=field_id id=field_id
type="file" type="file"
@@ -1826,12 +1859,24 @@ fn AlternativeCard(
class="alt-submit" class="alt-submit"
disabled=move || { disabled=move || {
(!has_requirements && pending_transitions.get().is_empty()) (!has_requirements && pending_transitions.get().is_empty())
|| sending.get()
|| submit.pending().get() || submit.pending().get()
|| transition_batch.pending().get() || transition_batch.pending().get()
} }
> >
{button_label.clone()} {button_label.clone()}
</button> </button>
// Why nothing happened, when something
// was refused: it used to say nothing.
{move || {
send_error
.get()
.or_else(|| match submit.value().get() {
Some(Err(e)) => Some(server_message(&e)),
_ => None,
})
.map(|m| view! { <p class="alt-error" role="alert">{m}</p> })
}}
</form> </form>
} }
.into_any() .into_any()
@@ -2238,6 +2283,13 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty()) rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
} }
/// What a refused server call says, without the wrapping a person
/// has no use for.
fn server_message(e: &ServerFnError) -> String {
let text = e.to_string();
text.rsplit_once(": ").map(|(_, m)| m.to_string()).filter(|m| !m.is_empty()).unwrap_or(text)
}
/// Whether an uploaded file is a picture a browser shows as it is. /// Whether an uploaded file is a picture a browser shows as it is.
pub fn is_picture(filename: &str) -> bool { pub fn is_picture(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase(); let lower = filename.to_ascii_lowercase();
@@ -2559,6 +2611,7 @@ pub async fn get_question(
let session: tower_sessions::Session = leptos_axum::extract().await?; let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?; let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
fill_from_user(&mut question, user.as_ref()); fill_from_user(&mut question, user.as_ref());
crate::answers::fill_own(&state.jetstream, &mut question, user.as_ref()).await;
let mut chain_gate = None; let mut chain_gate = None;
if let Some(target) = &question.requires_chain { if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required // The claimed lineage must verifiably end at the required
@@ -2912,6 +2965,26 @@ pub async fn submit_answer(
let mut responses: serde_json::Value = serde_json::from_str(&responses_json) let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?; .map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
// One card each: whose it is comes from the session, and a file the
// person did not choose again is the one already on their card.
let owner = if alt.one_per_person {
let user = user.as_ref().ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?;
let bucket = record_as.clone().ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?;
let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username).await.into_iter().next();
if let Some(map) = responses.as_object_mut() {
for req in alt.features.iter().flat_map(|f| &f.requirements).filter(|r| r.kind == "file") {
let chosen = map.get(&req.name).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty());
if let (false, Some(kept)) = (chosen, previous.as_ref().and_then(|p| p.responses.get(&req.name))) {
map.insert(req.name.clone(), kept.clone());
}
}
map.insert(crate::answers::OWNER_FIELD.to_string(), serde_json::Value::String(user.username.clone()));
}
Some((bucket, user.username.clone()))
} else {
None
};
// A record that belongs to another record: check the other one is // A record that belongs to another record: check the other one is
// there before this one is written, so a reference in a stored // there before this one is written, so a reference in a stored
// record is one that resolved at least once. An optional field // record is one that resolved at least once. An optional field
@@ -3027,6 +3100,15 @@ pub async fn submit_answer(
{ {
tracing::error!("failed to store answer in {bucket}: {e}"); tracing::error!("failed to store answer in {bucket}: {e}");
} }
if let Some((own_bucket, username)) = &owner {
let initial = state
.aggregates
.load()
.get(own_bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
crate::answers::keep_newest_own(&state.jetstream, own_bucket, &initial, username).await;
}
// Arrival is a state too: what the schema says the first // Arrival is a state too: what the schema says the first
// state sends, it sends now. // state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) { if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
+8
View File
@@ -343,6 +343,14 @@ pub struct Alternative {
/// works (lysbue had the same flag). /// works (lysbue had the same flag).
#[serde(default)] #[serde(default)]
pub disabled: bool, pub disabled: bool,
/// Each signed-in person keeps one record here: their own, which
/// sending again replaces. The form opens with what their record
/// says, a file left empty keeps the one already there, and the
/// older record leaves the list. For what a person says about
/// themselves - a card on the front page, a profile. Needs
/// `record_as`, and someone signed in.
#[serde(default)]
pub one_per_person: bool,
#[serde(default)] #[serde(default)]
pub consequence: Vec<String>, pub consequence: Vec<String>,
#[serde(default)] #[serde(default)]
+7
View File
@@ -512,6 +512,13 @@ pub fn validate_questions(
} }
for question in questions.values() { for question in questions.values() {
for alternative in &question.alternatives { for alternative in &question.alternatives {
if alternative.one_per_person && alternative.record_as.is_none() {
anyhow::bail!(
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
question.id,
alternative.name
);
}
if let Some(bucket) = &alternative.record_as { if let Some(bucket) = &alternative.record_as {
let attended = read_buckets.contains(bucket) let attended = read_buckets.contains(bucket)
|| aggregates || aggregates
+9
View File
@@ -24,6 +24,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("en", "sign_in") => "Sign in", ("en", "sign_in") => "Sign in",
("en", "sign_out") => "Sign out", ("en", "sign_out") => "Sign out",
("en", "nothing_here_yet") => "Nothing here yet.", ("en", "nothing_here_yet") => "Nothing here yet.",
("en", "upload_failed") => "The file could not be sent, so nothing was. Try again, or with a smaller file.",
("en", "current_file") => "Now:",
("en", "keep_file") => "Choose a new one to replace it, or leave it as it is.",
("en", "nothing_here") => "Nothing here", ("en", "nothing_here") => "Nothing here",
("en", "back_to_start") => "back to the start", ("en", "back_to_start") => "back to the start",
("en", "done") => "Done.", ("en", "done") => "Done.",
@@ -54,6 +57,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
("no", "sign_in") => "Logg inn", ("no", "sign_in") => "Logg inn",
("no", "sign_out") => "Logg ut", ("no", "sign_out") => "Logg ut",
("no", "nothing_here_yet") => "Ingenting her ennå.", ("no", "nothing_here_yet") => "Ingenting her ennå.",
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen, eller med en mindre fil.",
("no", "current_file") => "Nå:",
("no", "keep_file") => "Velg en ny for å bytte, eller la den være.",
("no", "nothing_here") => "Ingenting her", ("no", "nothing_here") => "Ingenting her",
("no", "back_to_start") => "tilbake til start", ("no", "back_to_start") => "tilbake til start",
("no", "done") => "Ferdig.", ("no", "done") => "Ferdig.",
@@ -102,6 +108,9 @@ mod tests {
"sign_in", "sign_in",
"sign_out", "sign_out",
"nothing_here_yet", "nothing_here_yet",
"upload_failed",
"current_file",
"keep_file",
"nothing_here", "nothing_here",
"back_to_start", "back_to_start",
"done", "done",
+15 -2
View File
@@ -433,6 +433,13 @@ pub struct PlanNeed {
/// untouched either way. /// untouched either way.
#[serde(default)] #[serde(default)]
pub paused: bool, pub paused: bool,
/// Each person in the group that enters it keeps one record: their
/// own card, which they open filled in and send again to change.
/// For what people say about themselves - who they are on the
/// front page. Needs `enters_as`, so there is someone signed in to
/// own it.
#[serde(default)]
pub one_per_person: 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.
@@ -756,6 +763,9 @@ impl Plan {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to))); bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
} }
} }
if need.one_per_person && need.enters_as.is_none() {
bail!(at("one_per_person keeps a card for each signed-in person, and the need has no enters_as to sign them in"));
}
if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) { if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by")); bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by"));
} }
@@ -1985,6 +1995,9 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
"record_as": need.lands_in, "record_as": need.lands_in,
"features": features, "features": features,
}); });
if need.one_per_person {
alt["one_per_person"] = json!(true);
}
// Paused, it records nothing and says where nothing goes. // Paused, it records nothing and says where nothing goes.
if need.paused { if need.paused {
alt["disabled"] = json!(true); alt["disabled"] = json!(true);
@@ -2550,7 +2563,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, 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(", "))), .map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, match (n.paused, n.one_per_person) { (true, _) => " (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)", (_, true) => " (ONE EACH: every signed-in person keeps one record here, their own; the form opens filled in with it and sending saves the change, so it speaks to them about their own and its button saves)", _ => "" }, 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.",
@@ -2707,7 +2720,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, paused: false, grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false, one_per_person: 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.
- **Let people keep their own.** `one_per_person: true` on a need
entered from inside gives each signed-in person one record there:
their own, which the form opens with and which sending again
replaces - a card on the front page they edit themselves. A picture
left unchosen stays as it was.
- **Pause what you cannot take on.** `paused: true` on a need keeps - **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 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 taking new ones right now, with no form and a button that does
+17
View File
@@ -1325,6 +1325,23 @@ textarea:focus {
object-fit: cover; object-fit: cover;
} }
/* Why a send was refused, under its button. */
.alt-error {
margin: 0.6em 0 0;
color: var(--accent);
font-size: 0.92em;
}
/* The file already on a card of one's own, above the picker. */
.field-current {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6em;
font-size: 0.9em;
color: var(--ink-dim);
}
/* A stored file on a record: its name, and it opens. */ /* A stored file on a record: its name, and it opens. */
.answer-file { .answer-file {
color: var(--accent); color: var(--accent);
+29
View File
@@ -248,3 +248,32 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
let groups = memberships::on_signed_in(&state, &kari).await; let groups = memberships::on_signed_in(&state, &kari).await;
assert!(groups.contains(&styret.to_string())); assert!(groups.contains(&styret.to_string()));
} }
#[tokio::test]
async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
use portal::answers::{keep_newest_own, own_records, OWNER_FIELD};
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
ensure_stream(&js).await.expect("event stream");
let bucket = format!("own_{}", chrono::Utc::now().timestamp_millis());
let schemas = std::collections::HashMap::new();
let now = chrono::Utc::now().timestamp_millis();
let card = |name: &str, by: &str| json!({"name": name, OWNER_FIELD: by});
// Kari's first card, then two sends of her second pressed at once,
// and Ola's card beside them.
store_answer(&js, &schemas, &bucket, "k1".into(), "/kontor/ansatt", "a", &card("Kari", "kari"), now, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "o1".into(), "/kontor/ansatt", "a", &card("Ola", "ola"), now + 1, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "k2".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
store_answer(&js, &schemas, &bucket, "k3".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
// Both racing sends settle after storing; both must keep the same one.
let (a, b) = tokio::join!(keep_newest_own(&js, &bucket, "open", "kari"), keep_newest_own(&js, &bucket, "open", "kari"));
let _ = (a, b);
let kari = own_records(&js, &bucket, "kari").await;
assert_eq!(kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(), vec!["k3"], "one card, the newest");
assert_eq!(own_records(&js, &bucket, "ola").await.len(), 1, "nobody else's card is touched");
}