use serde::{Deserialize, Serialize};
use super::event::{ItemKind, TriageDecision};
use super::state::Funnel;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryStatus {
Untriaged,
Accepted,
Dropped,
Planned,
}
impl HistoryStatus {
pub fn as_str(&self) -> &'static str {
match self {
HistoryStatus::Untriaged => "untriaged",
HistoryStatus::Accepted => "accepted",
HistoryStatus::Dropped => "dropped",
HistoryStatus::Planned => "planned",
}
}
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,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HistoryItem {
pub id: String,
pub item_kind: ItemKind,
pub text: String,
pub source: String,
pub submitted_at: String,
pub status: HistoryStatus,
pub plan_ids: Vec<String>,
}
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();
items.sort_by(|a, b| {
b.submitted_at.cmp(&a.submitted_at).then(a.id.cmp(&b.id))
});
items
}
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::funnel::event::{Event, PlanStatus};
use crate::funnel::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
}
#[test]
fn history_derives_kind_status_and_filters() {
let base = Utc::now();
let at = |i: i64| base + chrono::Duration::seconds(i);
let events = vec![
Event::IdeaSubmitted {
id: IdeaId::seq(1),
source: "cli".into(),
text: "add a dark theme".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: at(0),
},
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),
},
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);
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);
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"));
let planned = history(&f, None, Some(HistoryStatus::Planned));
assert_eq!(planned.len(), 1);
assert_eq!(planned[0].id, "i-003");
assert!(history(&f, Some(ItemKind::Error), Some(HistoryStatus::Planned)).is_empty());
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");
}
}