use super::{alias_link, escape};
use crate::event::Event;
use crate::model::{Aggregates, Node, Tree};
use crate::render::{anchor_of, blocking_of, open_then_of, standing_of, Full};
fn link(project: &str, tree: &Tree, n: &Node) -> String {
format!(
"<li><span class=\"alias\"><a href=\"/p/{p}/why/{a}\">{a}</a></span>\
<p class=\"title\">{t}</p></li>\n",
p = escape(project),
a = escape(&n.alias()),
t = escape(n.title(tree))
)
}
fn weight(
project: &str,
tree: &Tree,
standing: &[&Node],
open_then: &[&Node],
blocking: &[&Node],
notes: &[(&str, &str)],
) -> String {
if standing.is_empty() && open_then.is_empty() && blocking.is_empty() && notes.is_empty() {
return String::new();
}
let counts = [
format!("waiting on {}", blocking.len()),
format!("{} governing here", standing.len()),
format!("{} open then", open_then.len()),
format!(
"{} note{}",
notes.len(),
if notes.len() == 1 { "" } else { "s" }
),
]
.iter()
.zip([blocking.len(), standing.len(), open_then.len(), notes.len()])
.filter(|(_, n)| *n > 0)
.map(|(text, _)| format!("<span class=\"count\">{text}</span>"))
.collect::<Vec<_>>()
.join(" ");
let mut body = String::new();
if !blocking.is_empty() {
body.push_str("<h3>Does not close until these close</h3>\n<ul class=\"nodes\">\n");
for n in blocking {
body.push_str(&link(project, tree, n));
}
body.push_str("</ul>\n");
}
if !standing.is_empty() {
body.push_str("<h3>Decided here, still standing</h3>\n<ul class=\"nodes\">\n");
for n in standing {
body.push_str(&link(project, tree, n));
}
body.push_str("</ul>\n");
}
if !open_then.is_empty() {
body.push_str("<h3>Still open at that moment</h3>\n<ul class=\"nodes\">\n");
for n in open_then {
body.push_str(&link(project, tree, n));
}
body.push_str("</ul>\n");
}
if !notes.is_empty() {
body.push_str("<h3>Notes</h3>\n");
for (at, text) in notes {
if notes.len() > 1 {
body.push_str(&format!(
"<p class=\"note\"><span class=\"when\">{}</span> {}</p>\n",
escape(crate::clock::date_of(at)),
escape(text)
));
} else {
body.push_str(&format!("<p class=\"note\">{}</p>\n", escape(text)));
}
}
}
format!(
"<details><summary>{counts}</summary>\n<div class=\"detail\">\n{body}</div>\n</details>\n"
)
}
fn facts(tree: &Tree, ag: &Aggregates, full: &Full, n: &Node) -> String {
let mut parts = vec![
format!("<span class=\"word\">{}</span>", n.kind.word()),
format!("<span class=\"word\">{}</span>", n.state.word(n.kind)),
format!(
"<span class=\"when\">opened {}</span>",
escape(n.opened(tree))
),
];
let anchor = anchor_of(tree, full, n);
if !anchor.is_empty_tree() {
parts.push(format!(
"<span class=\"anchor\">{} {}</span>",
escape(&anchor.kind),
escape(anchor.short())
));
}
let open_below = ag.counts(n.num).open_count;
if open_below > 0 {
parts.push(format!(
"<span class=\"count\">{open_below} open below</span>"
));
}
format!("<p class=\"facts\">{}</p>\n", parts.join(" "))
}
fn step(project: &str, tree: &Tree, ag: &Aggregates, full: &Full, n: &Node, here: bool) -> String {
let mark = if here {
"<span class=\"here-mark\">you are here</span>"
} else {
""
};
let alias = if here {
escape(&n.alias())
} else {
alias_link(project, &n.alias())
};
format!(
"<li{cls}>\n<span class=\"alias\">{alias}</span>\n<div class=\"what\">\n\
<p class=\"title\">{title}{mark}</p>\n{facts}{weight}</div>\n</li>\n",
cls = if here { " class=\"here\"" } else { "" },
title = escape(n.title(tree)),
facts = facts(tree, ag, full, n),
weight = weight(
project,
tree,
&standing_of(tree, n),
&open_then_of(tree, full, n),
&blocking_of(tree, n),
&n.notes(tree),
),
)
}
pub(super) fn why_page(
project: &str,
name: &str,
tree: &Tree,
log: &[Event],
id: &str,
) -> Option<String> {
let target = tree.resolve(id)?;
let full = Full::from_log(log);
let ag = tree.aggregates();
let path = tree.ancestors(target.num);
let last = path.len().saturating_sub(1);
let spine: String = path
.iter()
.enumerate()
.map(|(i, n)| step(project, tree, &ag, &full, n, i == last))
.collect();
Some(format!(
"<!doctype html>\n\
<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>Why {alias} - {name_t}</title>\n\
<style>\n{css}</style></head>\n\
<body><div class=\"page\">\n\
<header><p class=\"crumb\"><a href=\"/p/{p}/\">{name_t}</a></p>\n\
<h1>Why we are here</h1>\n\
<p class=\"promise\">The shape of the path at a glance: how deep it goes, \
where it branched, and what was still open at each step.</p></header>\n\
<main>\n<ol class=\"spine\">\n{spine}</ol>\n</main>\n\
<footer>The same reading in a terminal: \
<code>vivac why {alias} --full</code></footer>\n\
</div></body></html>\n",
alias = escape(&target.alias()),
name_t = escape(name),
p = escape(project),
css = super::WEB_CSS,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Body, Event, Kind, State};
use crate::model::fold;
fn ev(seq: u64, payload: Body) -> Event {
Event {
seq,
id: format!("e{seq}"),
ts: "2026-09-03T10:00:00Z".to_string(),
actor: "a".to_string(),
lane: "main".to_string(),
payload,
}
}
fn born(seq: u64, num: u64, kind: Kind, title: &str, parent: Option<&str>) -> Event {
ev(
seq,
Body::NodeCreated {
node: format!("n{num}"),
num,
kind,
title: title.to_string(),
why: "it is needed".to_string(),
parent: parent.map(str::to_string),
blocks: false,
refs: vec![],
governs: vec![],
arms: vec![],
},
)
}
fn closed(seq: u64, num: u64) -> Event {
ev(
seq,
Body::StateChanged {
node: format!("n{num}"),
state: State::Done,
outcome: String::new(),
forced: false,
},
)
}
fn noted(seq: u64, num: u64, note: &str) -> Event {
ev(
seq,
Body::NodeNoted {
node: format!("n{num}"),
note: note.to_string(),
},
)
}
fn lineage() -> Vec<Event> {
vec![
born(1, 1, Kind::Goal, "ship it", None),
born(2, 2, Kind::Decision, "the face is web", Some("n1")),
born(3, 3, Kind::Task, "the sibling", Some("n2")),
born(4, 4, Kind::Finding, "the anchor is empty", Some("n2")),
closed(5, 3),
]
}
#[test]
fn the_spine_has_one_step_per_node_of_the_path() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert_eq!(
page.matches("class=\"facts\"").count(),
3,
"one step per node of g1 > d2 > f4"
);
assert!(page.contains("ship it"));
assert!(page.contains("the face is web"));
assert!(page.contains("the anchor is empty"));
}
#[test]
fn the_last_step_is_the_node_that_was_asked_for() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
let here = page.find("you are here").expect("the step is marked");
let target = page
.find("the anchor is empty")
.expect("the target is drawn");
let middle = page
.find("the face is web")
.expect("the middle step is drawn");
assert!(middle < target, "the spine runs from the root down");
assert!(target < here, "the mark sits on the step it belongs to");
}
#[test]
fn the_step_you_are_on_is_marked_by_a_word_and_not_only_by_a_class() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(page.contains("you are here"));
assert!(page.contains("class=\"here\""));
}
#[test]
fn a_title_that_looks_like_markup_reaches_the_lineage_escaped() {
let events = vec![born(1, 1, Kind::Goal, "<script>alert(1)</script>", None)];
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "g1").unwrap();
assert!(!page.contains("<script>alert(1)</script>"));
assert!(page.contains("<script>"));
}
#[test]
fn a_step_with_no_anchor_draws_no_anchor_row() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(!page.contains("class=\"anchor\""));
}
#[test]
fn a_step_with_nothing_to_expand_has_no_disclosure() {
let events = vec![born(1, 1, Kind::Goal, "ship it", None)];
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "g1").unwrap();
assert!(!page.contains("<details>"));
}
#[test]
fn what_closed_below_a_step_is_a_count_and_never_a_list() {
let mut events = lineage();
events.push(born(6, 5, Kind::Task, "the buried one", Some("n4")));
events.push(closed(7, 5));
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(page.contains("open below"), "the counts are drawn");
assert!(
!page.contains("the buried one"),
"what closed below is counted, not listed"
);
}
#[test]
fn every_step_but_the_one_you_are_on_links_to_its_own_lineage() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(page.contains("href=\"/p/vivac/why/g1\""), "{page}");
assert!(page.contains("href=\"/p/vivac/why/d2\""), "{page}");
assert!(!page.contains("href=\"/p/vivac/why/f4\""), "{page}");
}
#[test]
fn an_alias_that_resolves_to_nothing_gives_no_page() {
let events = lineage();
let tree = fold(&events, 0);
assert!(why_page("vivac", "vivac", &tree, &events, "f999").is_none());
}
#[test]
fn the_lineage_asks_for_nothing_off_this_machine() {
let events = lineage();
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(!page.contains("http://"));
assert!(!page.contains("https://"));
assert!(!page.contains("//cdn"));
}
#[test]
fn a_step_with_two_notes_lists_both_with_their_own_date() {
let mut events = lineage();
events.push(noted(6, 4, "first note"));
events.push(noted(7, 4, "second note"));
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(
page.contains("<span class=\"when\">2026-09-03</span> first note"),
"the first note should carry its own date:\n{page}"
);
assert!(
page.contains("<span class=\"when\">2026-09-03</span> second note"),
"the second note should carry its own date:\n{page}"
);
}
#[test]
fn a_step_with_one_note_carries_no_date() {
let mut events = lineage();
events.push(noted(6, 4, "the only note"));
let tree = fold(&events, 0);
let page = why_page("vivac", "vivac", &tree, &events, "f4").unwrap();
assert!(
page.contains("<p class=\"note\">the only note</p>"),
"{page}"
);
assert!(
!page.contains("<span class=\"when\">2026-09-03</span> the only note"),
"a single note must not carry a date:\n{page}"
);
}
}