Announced pages: event windows, header announcements, summary tasks

A question may carry event: {starts, duration, place}. While the
window is open the page is announced in a strip at the top of every
header (name, when, 'in 3 days'), soonest first, and kept out of the
footer nav; when it closes the page becomes a followup - only a
visitor carrying an answer chain still sees it.

announce.rs keeps one record per event page in the runtime-owned
portal_events bucket (built-in state graph: announced ->
awaiting_summary -> summarized, content may override) and, on a
one-minute idempotent sweep, moves ended windows to awaiting_summary,
publishing the transition on portal.answers.submitted as 'Summary
due' - the post-what-happened task a review desk picks up. Lint
warns when event pages exist but nothing reads portal_events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-30 12:34:34 +02:00
co-authored by Claude Fable 5
parent 47d7f9d2c3
commit 0154f6c8c0
8 changed files with 558 additions and 4 deletions
+128 -2
View File
@@ -98,6 +98,21 @@ struct QuestionRes(Resource<Result<Option<Page>, ServerFnError>>);
struct UserRes(Resource<Result<Option<User>, ServerFnError>>);
#[derive(Clone, Copy)]
struct NavRes(Resource<Result<Vec<(String, String)>, ServerFnError>>);
#[derive(Clone, Copy)]
struct AnnounceRes(Resource<Result<Vec<Announcement>, ServerFnError>>);
/// One live announced page (`Question.event`), pre-formatted on the
/// server so both renders agree and no client clock is involved.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Announcement {
pub id: String,
pub name: String,
/// "Sat 12 Sep, 18:00"
pub when: String,
/// "in 3 days" / "in 4 h" / "now"
pub relative: String,
pub place: Option<String>,
}
/// Owns the app's data resources, above the routes and reactive on the
/// location instead of recreated per page. Route components creating
@@ -129,9 +144,11 @@ fn PortalShell() -> impl IntoView {
);
let user = Resource::new(|| (), |_| current_user());
let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
let announcements = Resource::new(|| (), |_| list_announcements());
provide_context(QuestionRes(question));
provide_context(UserRes(user));
provide_context(NavRes(nav));
provide_context(AnnounceRes(announcements));
view! {
<Routes fallback=|| view! { <NotFound/> }>
@@ -219,6 +236,7 @@ fn QuestionView(
title=question.name.clone()
description=question.description.clone()
landing=question_id == "/"
current_id=question_id.clone()
site=site.clone()
/>
<div class="alternatives">
@@ -245,6 +263,7 @@ fn QuestionView(
title=question.name.clone()
description=question.description.clone()
landing=question_id == "/"
current_id=question_id.clone()
site=site.clone()
/>
<div class="alternatives">
@@ -284,6 +303,7 @@ fn QuestionView(
description=question.description.clone()
landing=question_id == "/"
site=site.clone()
current_id=question_id.clone()
/>
<div class="alternatives">
<For
@@ -367,6 +387,45 @@ fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
}
}
/// Live announced pages, as a strip at the top of the header - the one
/// place a page gets to claim attention before the question. Empty
/// (and unrendered) when nothing is announced.
#[component]
fn Announcements(current_id: String) -> impl IntoView {
let AnnounceRes(announcements) = expect_context();
view! {
<Suspense fallback=|| ()>
{move || {
let current_id = current_id.clone();
announcements.get().and_then(|res| res.ok()).map(|items| {
(!items.is_empty()).then(|| view! {
<nav class="announce" aria-label="Announcements">
<For
each=move || items.clone()
key=|a| a.id.clone()
children=move |a: Announcement| {
let current = a.id == current_id;
view! {
<a href=a.id.clone() class="announce-item" aria-current=current.then_some("page")>
<span class="announce-name">{a.name}</span>
<span class="announce-when">
{a.when}
{a.place.map(|p| format!(" · {p}"))}
</span>
<span class="announce-relative">{a.relative}</span>
</a>
}
.into_any()
}
/>
</nav>
})
})
}}
</Suspense>
}
}
/// "Asked by X — contact them if you get stuck."
/// The mailto address is assembled from `data-user`/`data-domain` on a
/// real mouse event, never baked into the server-rendered `href` -
@@ -460,7 +519,7 @@ mod gesture {
}
#[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView {
fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView {
// Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's
@@ -551,6 +610,7 @@ fn Hero(title: String, description: String, landing: bool, site: SiteConfig) ->
view! {
<header class="hero" class:hero-module=has_module>
<Announcements current_id=current_id/>
{has_module
.then(|| {
view! {
@@ -1815,12 +1875,17 @@ pub async fn list_qualifying_questions(
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let mut out: Vec<(String, String)> = state
.questions
.load()
.values()
.filter(|q| is_qualified(user.as_ref(), q))
.filter(|q| !q.is_followup() || has_chain)
// An ended event page is a followup from then on - only a
// visitor carrying a chain (they answered it) still sees it.
.filter(|q| !(q.is_followup() || q.event_ended_at(now_ms)) || has_chain)
// A live event is announced in the header instead.
.filter(|q| !q.event_live_at(now_ms))
// A dynamic page has no URL of its own to link to.
.filter(|q| !q.is_dynamic())
.map(|q| (q.id.clone(), q.name.clone()))
@@ -1829,6 +1894,67 @@ pub async fn list_qualifying_questions(
Ok(out)
}
/// Every announced page whose window is still open and that the
/// visitor qualifies for, soonest first.
#[server(endpoint = "list_announcements")]
pub async fn list_announcements() -> Result<Vec<Announcement>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::is_qualified;
use crate::server::AppState;
let state = expect_context::<AppState>();
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 now_ms = chrono::Utc::now().timestamp_millis();
let mut live: Vec<(i64, Announcement)> = state
.questions
.load()
.values()
.filter(|q| is_qualified(user.as_ref(), q) && q.event_live_at(now_ms))
.filter_map(|q| {
let event = q.event.as_ref()?;
let starts = chrono::DateTime::parse_from_rfc3339(&event.starts).ok()?;
let starts_ms = starts.timestamp_millis();
let relative = relative_until(starts_ms - now_ms);
Some((
starts_ms,
Announcement {
id: q.id.clone(),
name: q.name.clone(),
when: starts.format("%a %-d %b, %H:%M").to_string(),
relative,
place: event.place.clone(),
},
))
})
.collect();
live.sort_by_key(|(starts, _)| *starts);
Ok(live.into_iter().map(|(_, a)| a).collect())
}
/// "in 3 days" / "in 4 h" / "in 20 min" / "now" (already started).
#[cfg(feature = "ssr")]
fn relative_until(delta_ms: i64) -> String {
const MIN: i64 = 60_000;
const HOUR: i64 = 60 * MIN;
const DAY: i64 = 24 * HOUR;
if delta_ms <= 0 {
"now".to_string()
} else if delta_ms >= 2 * DAY {
format!("in {} days", delta_ms / DAY)
} else if delta_ms >= DAY {
"tomorrow".to_string()
} else if delta_ms >= HOUR {
format!("in {} h", delta_ms / HOUR)
} else {
format!("in {} min", (delta_ms / MIN).max(1))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubmitResult {
pub next: Option<String>,