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:
co-authored by
Claude Opus 5.5
parent
92dd9bfc79
commit
ae119af987
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user