//! Funnel **history** view (EPIC FUNNEL-INTAKE / FI2).
//!
//! A pure projection over a materialised [`Funnel`]: every submitted item
//! (idea OR error), newest-first, with its **derived status** (untriaged →
//! accepted/dropped → planned) and the plan ids that refine it (its triage→plan
//! lineage). The CLI (`nornir funnel history`), the server `Funnel.History` RPC,
//! and the viz Funnel-tab history list all render this same shape, so they never
//! disagree — and a test asserts the derivation on injected events.
use serde::{Deserialize, Serialize};
use super::event::{ItemKind, TriageDecision};
use super::state::Funnel;
/// Where a submitted item sits in the funnel pipeline. Derived, never stored —
/// it's a function of the item's triage + whether a plan was created for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryStatus {
/// Submitted, not yet triaged.
Untriaged,
/// Triaged `accept` but no plan yet.
Accepted,
/// Triaged `drop` — won't be planned.
Dropped,
/// At least one plan refines it (the loop closed: intake → tracked work).
Planned,
}
impl HistoryStatus {
/// The lower-case wire/CLI string.
pub fn as_str(&self) -> &'static str {
match self {
HistoryStatus::Untriaged => "untriaged",
HistoryStatus::Accepted => "accepted",
HistoryStatus::Dropped => "dropped",
HistoryStatus::Planned => "planned",
}
}
/// Parse a CLI/wire status filter; unknown/empty → `None` (no filter).
pub fn parse(s: &str) -> Option<Self> {
match s {
"untriaged" => Some(HistoryStatus::Untriaged),
"accepted" => Some(HistoryStatus::Accepted),
"dropped" => Some(HistoryStatus::Dropped),
"planned" => Some(HistoryStatus::Planned),
_ => None,
}
}
}
/// One submitted funnel item with its derived lineage — the unit the history
/// list (viz + CLI) renders.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HistoryItem {
pub id: String,
pub item_kind: ItemKind,
pub text: String,
pub source: String,
/// RFC3339 submission time.
pub submitted_at: String,
pub status: HistoryStatus,
/// Plan ids refining this item (its triage→plan lineage), sorted.
pub plan_ids: Vec<String>,
}
/// Build the history list from a funnel, **newest submission first**, optionally
/// filtered by `kind` and/or `status`. `None` filters keep everything.
///
/// Status derivation: a plan exists for the item ⇒ `Planned`; else its triage
/// decides — `accept` ⇒ `Accepted`, `drop` ⇒ `Dropped`, no triage ⇒ `Untriaged`.
pub fn history(
funnel: &Funnel,
kind: Option<ItemKind>,
status: Option<HistoryStatus>,
) -> Vec<HistoryItem> {
let mut items: Vec<HistoryItem> = funnel
.ideas
.values()
.map(|idea| {
let mut plan_ids: Vec<String> = funnel
.plans
.values()
.filter(|p| p.idea_id == idea.id)
.map(|p| p.id.as_str().to_string())
.collect();
plan_ids.sort();
let st = if !plan_ids.is_empty() {
HistoryStatus::Planned
} else {
match idea.triage.as_ref().map(|(d, _, _)| *d) {
Some(TriageDecision::Accept) => HistoryStatus::Accepted,
Some(TriageDecision::Drop) => HistoryStatus::Dropped,
None => HistoryStatus::Untriaged,
}
};
HistoryItem {
id: idea.id.as_str().to_string(),
item_kind: idea.item_kind,
text: idea.text.clone(),
source: idea.source.clone(),
submitted_at: idea.submitted_at.to_rfc3339(),
status: st,
plan_ids,
}
})
.filter(|it| kind.map(|k| it.item_kind == k).unwrap_or(true))
.filter(|it| status.map(|s| it.status == s).unwrap_or(true))
.collect();
// Newest first by submission time; tie-break on id for a stable order.
items.sort_by(|a, b| b.submitted_at.cmp(&a.submitted_at).then(a.id.cmp(&b.id)));
items
}
/// Serialise a history list to a pretty JSON array (the CLI's `history` output
/// and the viz history surface's `state_json` share this exact shape).
pub fn history_to_json(items: &[HistoryItem]) -> String {
serde_json::to_string_pretty(items).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Event, PlanStatus};
use crate::ids::{IdeaId, PlanId};
use chrono::Utc;
fn funnel_from(events: &[Event]) -> Funnel {
let mut f = Funnel::default();
for e in events {
f.apply(e).unwrap();
}
f
}
/// Inject an idea, an error, and an idea-with-plan; assert the history
/// derives the right kind + status for each, newest-first, and that the
/// kind/status filters select the expected subset.
#[test]
fn history_derives_kind_status_and_filters() {
let base = Utc::now();
let at = |i: i64| base + chrono::Duration::seconds(i);
let events = vec![
// i-001: a plain idea, untriaged.
Event::IdeaSubmitted {
id: IdeaId::seq(1),
source: "cli".into(),
text: "add a dark theme".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: at(0),
},
// i-002: an error report, untriaged (newest so far).
Event::IdeaSubmitted {
id: IdeaId::seq(2),
source: "cli".into(),
text: "panic: index out of bounds in foo.rs:42".into(),
refs: vec![],
item_kind: ItemKind::Error,
ts: at(1),
},
// i-003: an idea that got a plan → Planned (newest).
Event::IdeaSubmitted {
id: IdeaId::seq(3),
source: "cli".into(),
text: "ship the funnel history".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: at(2),
},
Event::PlanCreated {
id: PlanId::seq(1),
idea_id: IdeaId::seq(3),
summary: "FI2".into(),
planner: "cli".into(),
ts: at(3),
},
Event::PlanStatusChanged {
plan_id: PlanId::seq(1),
status: PlanStatus::Active,
why: None,
ts: at(4),
},
];
let f = funnel_from(&events);
// Full history: newest-first → i-003, i-002, i-001.
let all = history(&f, None, None);
assert_eq!(all.len(), 3);
assert_eq!(all[0].id, "i-003");
assert_eq!(all[0].status, HistoryStatus::Planned);
assert_eq!(all[0].plan_ids, vec!["p-001".to_string()]);
assert_eq!(all[1].id, "i-002");
assert_eq!(all[1].item_kind, ItemKind::Error);
assert_eq!(all[1].status, HistoryStatus::Untriaged);
assert_eq!(all[2].id, "i-001");
assert_eq!(all[2].item_kind, ItemKind::Idea);
// Kind filter: only the error.
let errs = history(&f, Some(ItemKind::Error), None);
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].id, "i-002");
assert!(errs[0].text.contains("panic"));
// Status filter: only the planned item.
let planned = history(&f, None, Some(HistoryStatus::Planned));
assert_eq!(planned.len(), 1);
assert_eq!(planned[0].id, "i-003");
// Combined filter that matches nothing (error + planned) → empty.
assert!(history(&f, Some(ItemKind::Error), Some(HistoryStatus::Planned)).is_empty());
// JSON is a well-formed array carrying the kind + status.
let json = history_to_json(&all);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.as_array().unwrap().len(), 3);
assert_eq!(parsed[0]["item_kind"], "idea");
assert_eq!(parsed[1]["item_kind"], "error");
}
}