From ae119af987b9e709ecf154c28827c6cab66d6c0c Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Fri, 2 Oct 2026 11:25:49 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MznPTC31w5mSUK4XMehcpi --- docs/content-reference.md | 8 +++++ src/answers.rs | 8 +++++ src/app.rs | 49 +++++++++++++++++++++++---- src/deadlines.rs | 2 +- src/i18n.rs | 3 ++ src/memberships.rs | 1 + src/people.rs | 1 + src/resource.rs | 69 +++++++++++++++++++++++++++++++++++++++ style/main.css | 19 +++++++++++ 9 files changed, 153 insertions(+), 7 deletions(-) diff --git a/docs/content-reference.md b/docs/content-reference.md index f592fe8..2be83cb 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -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 (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 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 diff --git a/src/answers.rs b/src/answers.rs index 73f8f18..04751b8 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -38,6 +38,11 @@ pub struct Answer { /// not see before. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub notes: Vec, + /// 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 @@ -97,6 +102,7 @@ pub async fn store_answer( storyline, decided_ms: None, notes: Vec::new(), + finished: false, decided_by: None, }; let data = serde_json::to_vec(&answer)?; @@ -537,6 +543,7 @@ mod tests { decided_ms: None, decided_by: None, notes: Vec::new(), + finished: false, }, Answer { id: "def456".to_string(), @@ -549,6 +556,7 @@ mod tests { decided_ms: Some(1_700_000_002_000), decided_by: Some("bl".to_string()), notes: Vec::new(), + finished: false, }, ]; let value = serde_json::Value::Array( diff --git a/src/app.rs b/src/app.rs index b1d3304..813b44e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1936,18 +1936,26 @@ fn ResourceFeature( pending_transitions: RwSignal>, transition_batch: ServerAction, ) -> 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 question_id = question_id.clone(); let alternative = alternative.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 @@ -1982,6 +1990,7 @@ fn ResourceFeature( value=value pending_transitions=pending_transitions transition_batch=transition_batch + finished_shown=finished_shown /> } .into_any() @@ -2011,6 +2020,7 @@ fn ResourceValue( value: serde_json::Value, pending_transitions: RwSignal>, transition_batch: ServerAction, + finished_shown: RwSignal, ) -> impl IntoView { // A jq like `.[] | {...}` over an empty list yields no output at // 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) }; 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! { + + } + }); return view! {
+ {show_older}
} .into_any(); diff --git a/src/deadlines.rs b/src/deadlines.rs index 757dad2..475aec2 100644 --- a/src/deadlines.rs +++ b/src/deadlines.rs @@ -194,7 +194,7 @@ aggregates: Answer { id: "r".into(), question_id: "/q".into(), alternative: "a".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, } } diff --git a/src/i18n.rs b/src/i18n.rs index af463f5..1ccecba 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -24,6 +24,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> { ("en", "sign_in") => "Sign in", ("en", "sign_out") => "Sign out", ("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_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.", @@ -60,6 +61,7 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> { ("no", "sign_in") => "Logg inn", ("no", "sign_out") => "Logg ut", ("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_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.", @@ -114,6 +116,7 @@ mod tests { "sign_in", "sign_out", "nothing_here_yet", + "show_older", "upload_failed", "upload_too_large", "upload_unavailable", diff --git a/src/memberships.rs b/src/memberships.rs index c0f412c..facef88 100644 --- a/src/memberships.rs +++ b/src/memberships.rs @@ -353,6 +353,7 @@ mod tests { decided_ms: None, decided_by: None, notes: Vec::new(), + finished: false, } } diff --git a/src/people.rs b/src/people.rs index 1c45136..98f7198 100644 --- a/src/people.rs +++ b/src/people.rs @@ -722,6 +722,7 @@ mod tests { decided_ms: None, decided_by: None, notes: Vec::new(), + finished: false, } } diff --git a/src/resource.rs b/src/resource.rs index 6dbb7df..8e7243c 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -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) } } @@ -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) -> 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, schema: &crate::aggregates::AggregateSchema, shown: usize) -> Vec { + 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 /// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if /// 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"])); } } + +#[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::>(); + + 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); + } +} diff --git a/style/main.css b/style/main.css index 28046a2..b392956 100644 --- a/style/main.css +++ b/style/main.css @@ -1280,6 +1280,25 @@ textarea:focus { 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 { border: 0.06rem solid var(--line); border-radius: 0.8rem;