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:
co-authored by
Claude Fable 5
parent
47d7f9d2c3
commit
0154f6c8c0
+128
-2
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user