src/viz/funnel_view.rs
//! Uniform, render-ready view of the idea→plan funnel (DAG 2).
//!
//! Both data paths collapse into [`FunnelView`]:
//! * **embedded** — [`FunnelView::from_funnel`] over a replayed
//! [`nornir::funnel::Funnel`] (the viz opened the funnel `Store` locally),
//! * **remote** — built from the `Funnel.Show` gRPC `FunnelDump` (see
//! [`super::remote::funnel_show`]); that RPC already carries per-node
//! `deps`, so the funnel tab needs **no** proto/server change.
//!
//! The [`funnel_tab`](super::funnel_tab) renderer consumes only this model, so
//! it is identical local vs. remote.
use eframe::egui::Color32;
use crate::funnel::{self, NodeStatus};
/// Whole-funnel snapshot: every plan and its node DAG.
#[derive(Clone, Debug, Default)]
pub struct FunnelView {
pub plans: Vec<PlanView>,
}
/// One submitted intake item (idea or error), newest-first, with its derived
/// triage→plan status + lineage — the unit the FI2 history list renders. Both
/// the embedded path ([`FunnelView::history`]) and the remote `Funnel.History`
/// RPC collapse into this.
#[derive(Clone, Debug, PartialEq)]
pub struct HistoryItemView {
pub id: String,
/// `idea` | `error`.
pub item_kind: String,
pub text: String,
pub source: String,
pub submitted_at: String,
/// `untriaged` | `accepted` | `dropped` | `planned`.
pub status: String,
pub plan_ids: Vec<String>,
}
impl HistoryItemView {
/// True when this item is an error report (the red-tinted rows).
pub fn is_error(&self) -> bool {
self.item_kind == "error"
}
}
/// One plan = one DAG (nodes + dependency edges).
#[derive(Clone, Debug)]
pub struct PlanView {
pub id: String,
pub summary: String,
/// `PlanStatus` rendered lower-case (`draft|active|done|abandoned`).
pub status: String,
/// The originating idea's text (context header); empty when unknown.
pub idea_text: String,
pub nodes: Vec<NodeView>,
}
/// A single plan node. `deps` are the node ids this node depends on
/// (i.e. edges `dep -> self`), the wiring the topo layout flows along.
#[derive(Clone, Debug)]
pub struct NodeView {
pub id: String,
pub kind: String,
pub title: String,
pub status: NodeStat,
pub targets: Vec<String>,
pub deps: Vec<String>,
}
/// Lifecycle, mirrored from [`funnel::NodeStatus`] plus an `Unknown` for
/// statuses arriving as a free string over the wire.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NodeStat {
Pending,
Ready,
InProgress,
Done,
Blocked,
Failed,
Unknown,
}
impl NodeStat {
/// Lenient parse of the wire/Debug spelling (`Funnel.Show` ships the
/// snake_case serde repr; the CLI accepts a few synonyms).
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"pending" => Self::Pending,
"ready" => Self::Ready,
"inprogress" | "in_progress" | "active" => Self::InProgress,
"done" => Self::Done,
"blocked" => Self::Blocked,
"failed" | "abandoned" => Self::Failed,
_ => Self::Unknown,
}
}
pub fn from_node(s: NodeStatus) -> Self {
match s {
NodeStatus::Pending => Self::Pending,
NodeStatus::Ready => Self::Ready,
NodeStatus::InProgress => Self::InProgress,
NodeStatus::Done => Self::Done,
NodeStatus::Blocked => Self::Blocked,
NodeStatus::Failed => Self::Failed,
}
}
/// Palette-aware node / pin tint — routed through the active facett palette so
/// a theme switch re-skins the funnel (C8). Greens = progressing, amber =
/// stuck, red = failed: Done→GREEN, InProgress→accent, Ready→point,
/// Pending→dim, Blocked→AMBER, Failed→RED, Unknown→dim.
pub fn color_themed(&self, theme: &super::facett_theme::Theme) -> Color32 {
use super::facett_theme::{AMBER, GREEN, RED};
match self {
Self::Done => GREEN,
Self::InProgress => theme.accent,
Self::Ready => theme.point,
Self::Pending => theme.text_dim,
Self::Blocked => AMBER,
Self::Failed => RED,
Self::Unknown => theme.text_dim,
}
}
/// Compact status glyph for the node header.
pub fn glyph(self) -> &'static str {
match self {
Self::Done => "✓",
Self::InProgress => "◐",
Self::Ready => "●",
Self::Pending => "○",
Self::Blocked => "‖",
Self::Failed => "✗",
Self::Unknown => "?",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Done => "done",
Self::InProgress => "in progress",
Self::Ready => "ready",
Self::Pending => "pending",
Self::Blocked => "blocked",
Self::Failed => "failed",
Self::Unknown => "unknown",
}
}
}
impl FunnelView {
/// Build from a locally-replayed funnel (embedded path).
pub fn from_funnel(f: &funnel::Funnel) -> Self {
let mut plans = Vec::new();
for plan in f.plans.values() {
let idea_text = f
.ideas
.get(&plan.idea_id)
.map(|i| i.text.clone())
.unwrap_or_default();
let mut nodes = Vec::with_capacity(plan.nodes.len());
for node in plan.nodes.values() {
// deps of `node` = every `from` with edge (from -> node).
let deps = plan
.edges
.iter()
.filter(|(_, to)| to == &node.id)
.map(|(from, _)| from.to_string())
.collect();
let title = node
.prompt_excerpt
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_default();
nodes.push(NodeView {
id: node.id.to_string(),
kind: node.kind.clone(),
title,
status: NodeStat::from_node(node.status),
targets: node.targets.clone(),
deps,
});
}
plans.push(PlanView {
id: plan.id.to_string(),
summary: plan.summary.clone(),
status: format!("{:?}", plan.status).to_ascii_lowercase(),
idea_text,
nodes,
});
}
// Stable, human order: by plan id.
plans.sort_by(|a, b| a.id.cmp(&b.id));
Self { plans }
}
/// FI2 — the intake history (newest-first), built from a locally-replayed
/// funnel via the shared [`crate::funnel::history`] projection so it derives
/// the exact same kind/status/lineage the CLI + server emit.
pub fn history(f: &funnel::Funnel) -> Vec<HistoryItemView> {
funnel::history(f, None, None)
.into_iter()
.map(|it| HistoryItemView {
id: it.id,
item_kind: it.item_kind.as_str().to_string(),
text: it.text,
source: it.source,
submitted_at: it.submitted_at,
status: it.status.as_str().to_string(),
plan_ids: it.plan_ids,
})
.collect()
}
}