From 0154f6c8c078f232e461a007ed459bbbfe78180d Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 30 Aug 2026 12:34:34 +0200 Subject: [PATCH] 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 --- README.md | 7 ++ src/announce.rs | 147 ++++++++++++++++++++++++++++ src/app.rs | 130 ++++++++++++++++++++++++- src/bin/question_lint.rs | 13 +++ src/content.rs | 204 ++++++++++++++++++++++++++++++++++++++- src/lib.rs | 3 + src/main.rs | 8 +- style/main.css | 50 ++++++++++ 8 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 src/announce.rs diff --git a/README.md b/README.md index e59fc04..6e24fe9 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,13 @@ stream. `requires_chain` gates a page on verifiable answer provenance next to `qualifies`' Kanidm-group identity gate (see `docs/design/filesystem-routes.md`). +- **Announced pages** (`src/announce.rs`): a question with an + `event: {starts, duration, place}` window is announced in the + header (not the footer nav) while the window is open, becomes a + followup when it closes, and its record in the runtime-owned + `portal_events` bucket moves to `awaiting_summary` - a "post what + happened" task any review desk can read, published on NATS like a + decision. A one-minute sweeper keeps it all idempotent. - **State machines as content** (`src/aggregates/`): `aggregates.yaml` declares each bucket's states and legal transitions; the engine replays a record's event history and refuses undeclared moves, with diff --git a/src/announce.rs b/src/announce.rs new file mode 100644 index 0000000..15c7335 --- /dev/null +++ b/src/announce.rs @@ -0,0 +1,147 @@ +//! Announced pages (`Question.event`): keeps one `portal_events` record +//! per event question and, once a window closes, moves it to +//! `awaiting_summary` - the "post what happened" task - publishing the +//! transition on `portal.answers.submitted` like any desk decision, so +//! the desktop notifier and n8n hear it. Runs on a one-minute tick; +//! every step is idempotent, so a restart or a content reload in the +//! middle changes nothing. + +use crate::answers::{store_answer, Answer}; +use crate::content::EVENTS_BUCKET; +use crate::events::{emit_answer_submitted, AnswerSubmitted}; +use crate::server::AppState; + +/// Alternative name stamped on the auto-created record and on the +/// transition event - what an n8n workflow gates on. +pub const EVENT_ALTERNATIVE: &str = "Announced"; +pub const SUMMARY_DUE_LABEL: &str = "Summary due"; + +/// Record id for an event question: its id with `/` folded to `-` +/// (`/events/opening` -> `events-opening`), so one page is one record +/// however many times the sweeper runs. +pub fn record_id(question_id: &str) -> String { + question_id.trim_matches('/').replace('/', "-") +} + +pub async fn run(state: AppState) { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + tick.tick().await; + if let Err(e) = sweep(&state).await { + tracing::warn!(error = %e, "event sweep failed"); + } + } +} + +/// One pass: create missing records, close ended windows. +pub async fn sweep(state: &AppState) -> anyhow::Result<()> { + let now_ms = chrono::Utc::now().timestamp_millis(); + let questions = state.questions.load(); + let events: Vec<_> = questions.values().filter(|q| q.event.is_some()).cloned().collect(); + if events.is_empty() { + return Ok(()); + } + let aggregates = state.aggregates.load(); + let schema = aggregates + .get(EVENTS_BUCKET) + .ok_or_else(|| anyhow::anyhow!("no {EVENTS_BUCKET} schema"))?; + let store = match state.jetstream.get_key_value(EVENTS_BUCKET).await { + Ok(store) => Some(store), + Err(_) => None, + }; + + for q in events { + let event = q.event.as_ref().expect("filtered"); + let (starts, ends) = match event.window_ms() { + Ok(w) => w, + Err(_) => continue, // validated on load; belt and braces + }; + let id = record_id(&q.id); + let existing: Option = match &store { + Some(store) => store + .get(&id) + .await? + .and_then(|bytes| serde_json::from_slice(&bytes).ok()), + None => None, + }; + let responses = serde_json::json!({ + "name": q.name, + "starts": event.starts, + "starts_ms": starts, + "ends_ms": ends, + "place": event.place, + "page": q.id, + }); + + let answer = match existing { + None => { + store_answer( + &state.jetstream, + &aggregates, + EVENTS_BUCKET, + id.clone(), + &q.id, + EVENT_ALTERNATIVE, + &responses, + now_ms, + ) + .await?; + tracing::info!(question = %q.id, "event record created"); + continue; + } + Some(a) => a, + }; + + if answer.state == schema.initial && now_ms >= ends { + let target = "awaiting_summary"; + let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" }); + match crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms).await { + Ok(_) => {} + Err(crate::aggregates::TransitionError::UnknownAggregate) => { + crate::aggregates::reseed(&state.jetstream, schema, &id, &answer.state, now_ms) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + } + Err(e) => return Err(anyhow::anyhow!("{e}")), + } + let mut answer = answer; + answer.state = target.to_string(); + answer.decided_ms = Some(now_ms); + answer.decided_by = Some("portal".to_string()); + let store = state.jetstream.get_key_value(EVENTS_BUCKET).await?; + store.put(&id, serde_json::to_vec(&answer)?.into()).await?; + + let parent_hashes = vec![id.clone()]; + let chain_hash = crate::chain::hash_node(&q.id, &parent_hashes, &payload, now_ms); + emit_answer_submitted( + &state.nats, + &AnswerSubmitted { + chain_hash, + parent_hashes, + question_id: q.id.clone(), + alternative: SUMMARY_DUE_LABEL.to_string(), + responses: payload, + timestamp_ms: now_ms, + }, + ) + .await?; + tracing::info!(question = %q.id, "event ended - summary due"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::record_id; + + #[test] + fn record_id_is_stable_and_flat() { + assert_eq!(record_id("/events/opening"), "events-opening"); + assert_eq!(record_id("/"), ""); + assert_eq!(record_id("/opening"), "opening"); + } +} diff --git a/src/app.rs b/src/app.rs index 6bcdb70..ce345a7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -98,6 +98,21 @@ struct QuestionRes(Resource, ServerFnError>>); struct UserRes(Resource, ServerFnError>>); #[derive(Clone, Copy)] struct NavRes(Resource, ServerFnError>>); +#[derive(Clone, Copy)] +struct AnnounceRes(Resource, 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, +} /// 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! { }> @@ -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() />
@@ -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() />
@@ -284,6 +303,7 @@ fn QuestionView( description=question.description.clone() landing=question_id == "/" site=site.clone() + current_id=question_id.clone() />
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! { + + {move || { + let current_id = current_id.clone(); + announcements.get().and_then(|res| res.ok()).map(|items| { + (!items.is_empty()).then(|| view! { + + }) + }) + }} + + } +} + /// "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! {
+ {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, ServerFnError> { + use crate::auth::{User, SESSION_USER_KEY}; + use crate::content::is_qualified; + use crate::server::AppState; + + let state = expect_context::(); + let session: tower_sessions::Session = leptos_axum::extract().await?; + let user = session + .get::(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, diff --git a/src/bin/question_lint.rs b/src/bin/question_lint.rs index 7c0d43b..feb46bb 100644 --- a/src/bin/question_lint.rs +++ b/src/bin/question_lint.rs @@ -52,8 +52,21 @@ async fn main() -> anyhow::Result<()> { eprintln!("FAIL: {e}"); std::process::exit(1); } + let aggregates_map = content::with_builtin_aggregates(aggregates_map); match content::validate_questions(&questions, &aggregates_map) { Ok(()) => { + // The runtime's own event desk: a repo announcing pages + // (Question.event) should read portal_events somewhere, or + // "post what happened" tasks pile up unseen. Warn, don't + // fail - the runtime creates the records either way. + if questions.values().any(|q| q.event.is_some()) + && !content::bucket_is_read(&questions, content::EVENTS_BUCKET) + { + eprintln!( + "WARN: pages carry `event:` but no kv resource reads bucket {:?} - add a desk so summaries get posted", + content::EVENTS_BUCKET + ); + } println!( "OK: {} question(s), {} aggregate(s) valid", questions.len(), diff --git a/src/content.rs b/src/content.rs index 2652e8f..1c1e7f0 100644 --- a/src/content.rs +++ b/src/content.rs @@ -50,6 +50,59 @@ pub struct Question { /// (not a followup). #[serde(default)] pub followup: Option, + /// This page announces something with a time window. While the + /// window is open the page is announced in the header (see + /// `app::Announcements`) instead of listed in the footer nav; once + /// it closes the page behaves like a `followup` (visible only to + /// visitors carrying an answer chain) and a "post what happened" + /// task lands in the `portal_events` desk (see `announce.rs`). + #[serde(default)] + pub event: Option, +} + +/// A page's announcement window. `starts` is RFC 3339 with an offset +/// (`2026-09-12T18:00:00+02:00`); `duration` is a plain span like +/// `3h`, `90m`, `2d`, `1d 6h`. Optional `place` is shown verbatim. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct EventConfig { + pub starts: String, + pub duration: String, + #[serde(default)] + pub place: Option, +} + +/// `3h`, `90m`, `2d`, `1d 6h 30m` -> milliseconds. Whole units only; +/// the smallest is a minute. +pub fn parse_duration_ms(text: &str) -> Result { + let mut total: i64 = 0; + let mut seen = false; + for token in text.split_whitespace() { + let (num, unit) = token.split_at(token.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len()); + let n: i64 = num.parse().map_err(|_| format!("bad duration {text:?}: {token:?}"))?; + let per = match unit { + "m" => 60_000, + "h" => 3_600_000, + "d" => 86_400_000, + _ => return Err(format!("bad duration {text:?}: unit {unit:?} (use m, h, d)")), + }; + total += n * per; + seen = true; + } + if !seen || total <= 0 { + return Err(format!("bad duration {text:?}: must be positive")); + } + Ok(total) +} + +#[cfg(feature = "ssr")] +impl EventConfig { + /// `(starts_ms, ends_ms)` as Unix milliseconds. + pub fn window_ms(&self) -> Result<(i64, i64), String> { + let starts = chrono::DateTime::parse_from_rfc3339(&self.starts) + .map_err(|e| format!("bad starts {:?}: {e} (RFC 3339 with offset)", self.starts))? + .timestamp_millis(); + Ok((starts, starts + parse_duration_ms(&self.duration)?)) + } } impl Question { @@ -57,6 +110,24 @@ impl Question { self.followup.unwrap_or(false) } + /// The announcement window is still open at `now_ms` (Unix ms). + #[cfg(feature = "ssr")] + pub fn event_live_at(&self, now_ms: i64) -> bool { + self.event + .as_ref() + .and_then(|e| e.window_ms().ok()) + .is_some_and(|(_, ends)| now_ms < ends) + } + + /// The announcement window has closed at `now_ms`. + #[cfg(feature = "ssr")] + pub fn event_ended_at(&self, now_ms: i64) -> bool { + self.event + .as_ref() + .and_then(|e| e.window_ms().ok()) + .is_some_and(|(_, ends)| now_ms >= ends) + } + /// A dynamic page - one whose id still contains a `[name]` /// segment. Served per-value via `resolve_question`, never listed /// in nav, never a valid `action` target. @@ -871,12 +942,71 @@ pub async fn load_questions_from_gitea( /// standalone `question-lint` binary, so a YAML typo becomes a caught, /// logged rejection instead of a silently-accepted, later-broken /// string. +/// Bucket the runtime keeps for announced pages (`Question.event`): +/// one record per event question, `announced` while the window is +/// open, `awaiting_summary` once it closes - the "post what happened" +/// task a desk reading this bucket picks up - and `summarized` when an +/// owner marks it done. Declared here, not in content, because the +/// runtime's sweeper (`announce.rs`) is what moves the records. +pub const EVENTS_BUCKET: &str = "portal_events"; + +#[cfg(feature = "ssr")] +const BUILTIN_AGGREGATES_YAML: &str = "\ +aggregates: + - bucket: portal_events + initial: announced + states: + announced: { event: announced } + awaiting_summary: { event: ended } + summarized: { event: summarized } + transitions: + announced: [awaiting_summary, summarized] + awaiting_summary: [summarized] +"; + +/// Adds the runtime's own state graphs to a content repo's. Content +/// wins if it declares the same bucket (say, to add states). +#[cfg(feature = "ssr")] +pub fn with_builtin_aggregates( + mut aggregates: std::collections::HashMap, +) -> std::collections::HashMap { + let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML) + .expect("builtin aggregates yaml is valid"); + for (bucket, schema) in builtin { + aggregates.entry(bucket).or_insert(schema); + } + aggregates +} + +/// True when some kv resource in the content reads `bucket` - the +/// attended-bucket check, exposed so the lint can warn about the +/// runtime's own `portal_events` (a warning, not a failure: a repo +/// with no event pages has nothing to attend). +#[cfg(feature = "ssr")] +pub fn bucket_is_read(questions: &std::collections::HashMap, bucket: &str) -> bool { + questions.values().any(|q| { + q.alternatives.iter().any(|a| { + a.features + .iter() + .any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket)) + }) + }) +} + #[cfg(feature = "ssr")] pub fn validate_questions( questions: &std::collections::HashMap, aggregates: &std::collections::HashMap, ) -> anyhow::Result<()> { for question in questions.values() { + if let Some(event) = &question.event { + if question.is_dynamic() { + anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id); + } + if let Err(e) = event.window_ms() { + anyhow::bail!("question {:?}: event: {e}", question.id); + } + } if let Some(target) = &question.requires_chain { if !questions.contains_key(target) { anyhow::bail!( @@ -1081,7 +1211,7 @@ pub async fn watch_for_reload( use futures::StreamExt; while sub.next().await.is_some() { let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await { - Ok(loaded) => loaded, + Ok(loaded) => with_builtin_aggregates(loaded), Err(e) => { tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content"); continue; @@ -1695,4 +1825,76 @@ alternatives: let questions = build_questions(&files).unwrap(); assert!(validate_questions(&questions, &Default::default()).is_err()); } + + // ── events (announced pages) ───────────────────────────────────────── + + #[test] + fn duration_parses_whole_units() { + assert_eq!(parse_duration_ms("3h").unwrap(), 3 * 3_600_000); + assert_eq!(parse_duration_ms("90m").unwrap(), 90 * 60_000); + assert_eq!(parse_duration_ms("1d 6h").unwrap(), 30 * 3_600_000); + assert!(parse_duration_ms("0h").is_err()); + assert!(parse_duration_ms("3 hours").is_err()); + assert!(parse_duration_ms("").is_err()); + } + + #[test] + fn event_window_and_liveness() { + let q: Question = serde_yaml::from_str( + "id: /opening\nname: Will you be there?\nevent:\n starts: 2026-09-12T18:00:00+02:00\n duration: 3h\n", + ) + .unwrap(); + let (starts, ends) = q.event.as_ref().unwrap().window_ms().unwrap(); + assert_eq!(ends - starts, 3 * 3_600_000); + assert!(q.event_live_at(starts - 1)); + assert!(q.event_live_at(ends - 1)); + assert!(!q.event_live_at(ends)); + assert!(q.event_ended_at(ends)); + assert!(!q.event_ended_at(starts)); + let plain: Question = serde_yaml::from_str("id: /x\nname: X\n").unwrap(); + assert!(!plain.event_live_at(0) && !plain.event_ended_at(i64::MAX)); + } + + #[test] + fn event_validation() { + let bad: Question = serde_yaml::from_str( + "id: /e\nname: E\nevent:\n starts: next friday\n duration: 3h\n", + ) + .unwrap(); + let mut qs = std::collections::HashMap::new(); + qs.insert(bad.id.clone(), bad); + let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string(); + assert!(err.contains("bad starts"), "{err}"); + + let dynamic: Question = serde_yaml::from_str( + "id: /e/[slot]\nname: E\nevent:\n starts: 2026-09-12T18:00:00Z\n duration: 3h\n", + ) + .unwrap(); + let mut qs = std::collections::HashMap::new(); + qs.insert(dynamic.id.clone(), dynamic); + let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string(); + assert!(err.contains("dynamic page can't carry an event"), "{err}"); + } + + #[test] + fn builtin_events_schema_yields_to_content() { + let merged = with_builtin_aggregates(Default::default()); + let schema = &merged[EVENTS_BUCKET]; + assert_eq!(schema.initial, "announced"); + assert_eq!(schema.allowed("announced"), ["awaiting_summary", "summarized"]); + assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]); + + let content = crate::aggregates::parse_aggregates_yaml( + "aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n", + ) + .unwrap(); + assert_eq!(with_builtin_aggregates(content)[EVENTS_BUCKET].initial, "mine"); + } + + #[test] + fn bucket_is_read_sees_kv_resources() { + let qs = question_with_transitions(" - { to: middle, label: Go }"); + assert!(bucket_is_read(&qs, "things")); + assert!(!bucket_is_read(&qs, EVENTS_BUCKET)); + } } diff --git a/src/lib.rs b/src/lib.rs index 37998d9..d54989e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,9 @@ pub mod resource; #[cfg(feature = "ssr")] pub mod aggregates; +#[cfg(feature = "ssr")] +pub mod announce; + #[cfg(feature = "ssr")] pub mod server; diff --git a/src/main.rs b/src/main.rs index e1cb265..dceaa38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,9 @@ async fn main() -> anyhow::Result<()> { let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string()); let gitea_base = content::gitea_api_base(&content_repo)?; let content_raw_base = content::gitea_raw_base(&content_repo)?; - let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?; + let aggregates = content::with_builtin_aggregates( + content::load_aggregates_from_gitea(&content_repo, &content_branch).await?, + ); let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; content::validate_questions(&questions, &aggregates)?; let site = content::load_site_from_gitea(&content_repo, &content_branch).await?; @@ -89,6 +91,10 @@ async fn main() -> anyhow::Result<()> { garage, }; + // Announced pages: keep their desk records current, close ended + // windows (see announce.rs). + tokio::spawn(portal::announce::run(state.clone())); + // Dev-friendly defaults: in-memory sessions, secure cookies only when // COOKIE_SECURE=true (set it behind TLS in production) - same // defaults cnats uses. SameSite=Lax (tower-sessions defaults to diff --git a/style/main.css b/style/main.css index b74c2c3..bca5bdd 100644 --- a/style/main.css +++ b/style/main.css @@ -206,6 +206,56 @@ main.not-found { min-height: 55svh; } +/* announcements - live event pages, a strip above the hero copy. + Header, not footer: the one place a page claims attention before + the question is asked. */ +.announce { + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.6rem 1.2rem; + margin: -2rem 0 0.5rem; + font-size: 0.88rem; + position: relative; + z-index: 1; +} + +.announce-item { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem 0.7rem; + padding: 0.45rem 0.9rem; + border: 0.06rem solid var(--line); + border-left: 0.2rem solid var(--accent); + border-radius: 0.3rem; + color: var(--ink); + text-decoration: none; + background: var(--paper); + transition: border-color 120ms; +} + +.announce-item:hover, +.announce-item[aria-current="page"] { + border-color: var(--accent); +} + +.announce-name { + font-weight: 600; +} + +.announce-when { + color: var(--ink-dim); +} + +.announce-relative { + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 0.72rem; +} + /* alternatives */ /* position + z-index on these three: everything that scrolls over the