A desk shows the three newest finished records, and older on request

Every record still moving stays; of those in a final state only the
three most recently decided, plus one so the page knows to offer
"Show older", three more at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-02 11:25:49 +02:00
co-authored by Claude Opus 5.5
parent 92dd9bfc79
commit ae119af987
9 changed files with 153 additions and 7 deletions
+8
View File
@@ -285,6 +285,14 @@ markdown on stricter rules than the site's own: paragraphs, line
breaks as typed, lists, emphasis and links to https, mailto or tel breaks as typed, lists, emphasis and links to https, mailto or tel
(marked `nofollow ugc`); no images, no raw HTML. (marked `nofollow ugc`); no images, no raw HTML.
A desk (a list with `transitions`) shows every record still moving,
and of those in a final state - one the state machine has no move out
of - only the three most recently decided, newest first. "Show older"
under the list brings three more each time it is pressed. The desk
stays about the work in hand however long the site has run; nothing
is hidden from a read-only list, and `leaves_after` still takes a
record out of every list altogether.
`states` and `where` narrow a listing. They matter most for a register `states` and `where` narrow a listing. They matter most for a register
that is public by law — public about what was decided, not about what that is public by law — public about what was decided, not about what
was withdrawn — while the desk working the same bucket is a different was withdrawn — while the desk working the same bucket is a different
+8
View File
@@ -38,6 +38,11 @@ pub struct Answer {
/// not see before. /// not see before.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Note>, pub notes: Vec<Note>,
/// Set by a desk listing, never stored: the record is in a final
/// state (one with no move out). A desk shows the newest few of
/// those and offers the rest on request (`resource::cap_finished`).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub finished: bool,
} }
/// One reply on a record. It lives here rather than with the mail /// One reply on a record. It lives here rather than with the mail
@@ -97,6 +102,7 @@ pub async fn store_answer(
storyline, storyline,
decided_ms: None, decided_ms: None,
notes: Vec::new(), notes: Vec::new(),
finished: false,
decided_by: None, decided_by: None,
}; };
let data = serde_json::to_vec(&answer)?; let data = serde_json::to_vec(&answer)?;
@@ -537,6 +543,7 @@ mod tests {
decided_ms: None, decided_ms: None,
decided_by: None, decided_by: None,
notes: Vec::new(), notes: Vec::new(),
finished: false,
}, },
Answer { Answer {
id: "def456".to_string(), id: "def456".to_string(),
@@ -549,6 +556,7 @@ mod tests {
decided_ms: Some(1_700_000_002_000), decided_ms: Some(1_700_000_002_000),
decided_by: Some("bl".to_string()), decided_by: Some("bl".to_string()),
notes: Vec::new(), notes: Vec::new(),
finished: false,
}, },
]; ];
let value = serde_json::Value::Array( let value = serde_json::Value::Array(
+43 -6
View File
@@ -1936,18 +1936,26 @@ fn ResourceFeature(
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView { ) -> impl IntoView {
// A desk shows the newest few finished records; "show older" asks
// for more (see `resource::cap_finished`).
let finished_shown = RwSignal::new(crate::resource::FINISHED_SHOWN);
let data = Resource::new( let data = Resource::new(
{ {
let question_id = question_id.clone(); let question_id = question_id.clone();
let alternative = alternative.clone(); let alternative = alternative.clone();
let feature_name = feature_name.clone(); let feature_name = feature_name.clone();
move || (question_id.clone(), alternative.clone(), feature_name.clone()) move || (question_id.clone(), alternative.clone(), feature_name.clone(), finished_shown.get())
},
// Forwarding sibling requirement values as query params (for a
// resource parameterized by other form fields) is deferred, see
// the resource-backed multi-select requirement UI follow-up.
|(q, a, f, shown)| {
let mut params = std::collections::HashMap::new();
if shown != crate::resource::FINISHED_SHOWN {
params.insert(crate::resource::FINISHED_PARAM.to_string(), shown.to_string());
}
get_resource(q, a, f, params)
}, },
// Empty params for now - forwarding sibling requirement values
// as query params (for a resource parameterized by other form
// fields) is deferred, see the resource-backed multi-select
// requirement UI follow-up.
|(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()),
); );
// The shared batch button lives on `AlternativeCard`, above every // The shared batch button lives on `AlternativeCard`, above every
@@ -1982,6 +1990,7 @@ fn ResourceFeature(
value=value value=value
pending_transitions=pending_transitions pending_transitions=pending_transitions
transition_batch=transition_batch transition_batch=transition_batch
finished_shown=finished_shown
/> />
} }
.into_any() .into_any()
@@ -2011,6 +2020,7 @@ fn ResourceValue(
value: serde_json::Value, value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>, transition_batch: ServerAction<TransitionAnswers>,
finished_shown: RwSignal<usize>,
) -> impl IntoView { ) -> impl IntoView {
// A jq like `.[] | {...}` over an empty list yields no output at // A jq like `.[] | {...}` over an empty list yields no output at
// all - that arrives as null, and it's the same silence as []. // all - that arrives as null, and it's the same silence as [].
@@ -2034,6 +2044,32 @@ fn ResourceValue(
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) }; let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
open(&a.state).cmp(&open(&b.state)).then(when) open(&a.state).cmp(&open(&b.state)).then(when)
}); });
// The desk sent one finished record more than it shows, when
// there are older ones: drop the oldest and offer the rest.
let shown = finished_shown.get_untracked();
let older = answers.iter().filter(|a| a.finished).count() > shown;
if older {
if let Some(oldest) = answers
.iter()
.enumerate()
.filter(|(_, a)| a.finished)
.min_by_key(|(_, a)| a.decided_ms.unwrap_or(a.submitted_ms))
.map(|(i, _)| i)
{
answers.remove(oldest);
}
}
let show_older = older.then(|| {
view! {
<button
type="button"
class="show-older"
on:click=move |_| finished_shown.update(|n| *n += crate::resource::FINISHED_SHOWN)
>
{move || t(&lang().get(), "show_older")}
</button>
}
});
return view! { return view! {
<div class="answer-list" class:answer-cards=cards> <div class="answer-list" class:answer-cards=cards>
<For <For
@@ -2060,6 +2096,7 @@ fn ResourceValue(
} }
} }
/> />
{show_older}
</div> </div>
} }
.into_any(); .into_any();
+1 -1
View File
@@ -194,7 +194,7 @@ aggregates:
Answer { Answer {
id: "r".into(), question_id: "/q".into(), alternative: "a".into(), id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
responses: serde_json::json!({}), state: state.into(), responses: serde_json::json!({}), state: state.into(),
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![], submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![], finished: false,
storyline: 0, storyline: 0,
} }
} }
+3
View File
@@ -24,6 +24,7 @@ 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", "show_older") => "Show older",
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.", ("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.", ("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.", ("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
@@ -60,6 +61,7 @@ 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", "show_older") => "Vis eldre",
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.", ("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.", ("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.", ("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
@@ -114,6 +116,7 @@ mod tests {
"sign_in", "sign_in",
"sign_out", "sign_out",
"nothing_here_yet", "nothing_here_yet",
"show_older",
"upload_failed", "upload_failed",
"upload_too_large", "upload_too_large",
"upload_unavailable", "upload_unavailable",
+1
View File
@@ -353,6 +353,7 @@ mod tests {
decided_ms: None, decided_ms: None,
decided_by: None, decided_by: None,
notes: Vec::new(), notes: Vec::new(),
finished: false,
} }
} }
+1
View File
@@ -722,6 +722,7 @@ mod tests {
decided_ms: None, decided_ms: None,
decided_by: None, decided_by: None,
notes: Vec::new(), notes: Vec::new(),
finished: false,
} }
} }
+69
View File
@@ -328,6 +328,11 @@ async fn fetch_resource_value(
} }
} }
} }
if !resource.transitions.is_empty() {
if let Some(schema) = state.aggregates.load().at(0).get(bucket) {
items = cap_finished(items, schema, finished_wanted(params));
}
}
serde_json::Value::Array(items) serde_json::Value::Array(items)
} }
} }
@@ -348,6 +353,41 @@ async fn fetch_resource_value(
} }
} }
/// How many finished records a desk listing should show: three, or
/// what the page asked for with `__finished` after "show older".
pub const FINISHED_PARAM: &str = "__finished";
pub const FINISHED_SHOWN: usize = 3;
#[cfg(feature = "ssr")]
fn finished_wanted(params: &std::collections::HashMap<String, String>) -> usize {
params.get(FINISHED_PARAM).and_then(|n| n.parse().ok()).unwrap_or(FINISHED_SHOWN)
}
/// A desk is for the cases still moving. Every record still open stays;
/// of those in a final state (no move out), only the `shown` most
/// recently decided, plus one more so the page knows there are older
/// ones to offer. Each finished record is marked, so the page can tell
/// them apart without knowing the state machine.
#[cfg(feature = "ssr")]
pub(crate) fn cap_finished(items: Vec<serde_json::Value>, schema: &crate::aggregates::AggregateSchema, shown: usize) -> Vec<serde_json::Value> {
let is_final = |v: &serde_json::Value| {
v.get("state").and_then(|s| s.as_str()).is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty())
};
let when = |v: &serde_json::Value| {
v.get("decided_ms").and_then(|d| d.as_i64()).or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64())).unwrap_or(0)
};
let (mut finished, mut open): (Vec<_>, Vec<_>) = items.into_iter().partition(is_final);
finished.sort_by_key(|v| std::cmp::Reverse(when(v)));
finished.truncate(shown + 1);
for v in &mut finished {
if let Some(obj) = v.as_object_mut() {
obj.insert("finished".into(), true.into());
}
}
open.extend(finished);
open
}
/// Fetches JSON from a path on the same Gitea instance content is /// Fetches JSON from a path on the same Gitea instance content is
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if /// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
/// set - confirmed live against `project.uhhm.no` that at least the /// set - confirmed live against `project.uhhm.no` that at least the
@@ -758,3 +798,32 @@ mod tests {
assert_eq!(out, serde_json::json!(["id: /x\n"])); assert_eq!(out, serde_json::json!(["id: /x\n"]));
} }
} }
#[cfg(all(test, feature = "ssr"))]
mod finished_tests {
use super::*;
#[test]
fn a_desk_keeps_every_open_case_and_the_newest_finished_ones() {
let schema = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: leads\n initial: open\n states:\n open: { event: in }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [won, lost]\n",
)
.unwrap()
.remove("leads")
.unwrap();
let rec = |id: &str, state: &str, decided: i64| serde_json::json!({ "id": id, "state": state, "submitted_ms": 0, "decided_ms": decided });
let mut items = vec![rec("o1", "open", 0), rec("o2", "open", 0)];
items.extend((1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n)));
let ids = |v: &[serde_json::Value]| v.iter().map(|x| x["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
let shown = cap_finished(items.clone(), &schema, 3);
// Both open cases, then the three newest finished and one more
// to tell the page there are older ones.
assert_eq!(ids(&shown), ["o1", "o2", "f6", "f5", "f4", "f3"]);
assert!(shown[2..].iter().all(|v| v["finished"] == true));
assert!(shown[..2].iter().all(|v| v.get("finished").is_none()));
// Asking for all of them: no extra, so no "show older".
assert_eq!(cap_finished(items, &schema, 6).len(), 8);
}
}
+19
View File
@@ -1280,6 +1280,25 @@ textarea:focus {
gap: 0.8rem; gap: 0.8rem;
} }
/* After the newest finished cases: the older ones, on request. Quiet,
since the desk is for what is still moving. */
.show-older {
justify-self: start;
background: none;
border: 0.06rem solid var(--line);
border-radius: 0.4rem;
color: var(--ink-dim);
font: inherit;
font-size: 0.9rem;
padding: 0.35rem 0.8rem;
cursor: pointer;
}
.show-older:hover {
color: inherit;
border-color: var(--ink-dim);
}
.answer-row { .answer-row {
border: 0.06rem solid var(--line); border: 0.06rem solid var(--line);
border-radius: 0.8rem; border-radius: 0.8rem;