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
+203
-1
@@ -50,6 +50,59 @@ pub struct Question {
|
||||
/// (not a followup).
|
||||
#[serde(default)]
|
||||
pub followup: Option<bool>,
|
||||
/// 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<EventConfig>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// `3h`, `90m`, `2d`, `1d 6h 30m` -> milliseconds. Whole units only;
|
||||
/// the smallest is a minute.
|
||||
pub fn parse_duration_ms(text: &str) -> Result<i64, String> {
|
||||
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<String, crate::aggregates::AggregateSchema>,
|
||||
) -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
|
||||
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<String, Question>, 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<String, Question>,
|
||||
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||||
) -> 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user