use super::{alias_link, escape};
use crate::changes::{self, Boundary, Changed};
use crate::event::Kind;
use crate::event::{Event, State};
use crate::model::{Node, Tree};
struct Line {
href: String,
name: String,
focus: Option<String>,
blocked: usize,
quiet: Option<i64>,
}
fn line(p: &mut crate::project::Project) -> Line {
let name = p.name.clone();
let href = p.slug.clone();
let Ok((ctx, log)) = p.current_with_log() else {
return Line {
href,
name,
focus: None,
blocked: 0,
quiet: None,
};
};
let href = log.first().map(|e| e.id.clone()).unwrap_or(href);
let tree = &ctx.tree;
let focus = tree
.focus()
.map(|n| format!("{} {}", n.alias(), n.title(tree)));
let blocked = tree
.nodes_iter()
.filter(|n| n.kind == Kind::Question && n.state.is_open() && n.blocks)
.count();
let quiet = log
.last()
.and_then(|e| crate::clock::days_between(&e.ts, &crate::clock::now_rfc3339()));
Line {
href,
name,
focus,
blocked,
quiet,
}
}
fn silence(days: Option<i64>) -> String {
match days {
None => "never written to".to_string(),
Some(d) if d <= 0 => "moved today".to_string(),
Some(1) => "moved yesterday".to_string(),
Some(d) => format!("moved {d} days ago"),
}
}
fn project_row(l: &Line) -> String {
let focus = match &l.focus {
Some(f) => format!("<p class=\"title\">{}</p>", escape(f)),
None => "<p class=\"note\">no focus</p>".to_string(),
};
let blocked = if l.blocked == 0 {
String::new()
} else if l.blocked == 1 {
" · 1 blocked".to_string()
} else {
format!(" · {} blocked", l.blocked)
};
format!(
"<li><span class=\"alias\"><a href=\"/p/{href}/\">{name}</a></span>
{focus}<p class=\"note\">{quiet}{blocked}</p></li>
",
href = escape(&l.href),
name = escape(&l.name),
quiet = silence(l.quiet),
)
}
fn listing(title: &str, promise: &str, rows: String, footer: &str) -> String {
format!(
"<!doctype html>
<html lang=\"en\"><head><meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>{t}</title>
<style>
{WEB_CSS}</style></head>
<body><div class=\"page\">
<header><h1>{t}</h1>
<p class=\"promise\">{promise}</p></header>
<main><ul class=\"nodes\">
{rows}</ul></main>
<footer>{footer}</footer>
</div></body></html>
",
t = escape(title),
)
}
pub(super) fn index_page(projects: &mut [crate::project::Project]) -> String {
let rows: String = projects.iter_mut().map(|p| project_row(&line(p))).collect();
listing(
"Projects",
"Which project moved, and which one has been sitting still.",
rows,
"The same reading in a terminal, one project at a time: <code>vivac open</code>",
)
}
pub(super) fn choose_page(projects: &mut [crate::project::Project], which: &[usize]) -> String {
let rows: String = which
.iter()
.map(|&i| project_row(&line(&mut projects[i])))
.collect();
listing(
"Which one?",
"More than one project goes by that name, so this page picks none of them.",
rows,
"The link under each name is permanent: it carries the project's own id, so it keeps opening this tree even when another of the same name joins.",
)
}
use super::WEB_CSS;
fn row(project: &str, tree: &Tree, n: &Node, note: &str, note_class: &str) -> String {
let mut s = format!(
"<li><span class=\"alias\">{}</span><p class=\"title\">{}</p>",
alias_link(project, &n.alias()),
escape(n.title(tree))
);
if !note.is_empty() {
let class = if note_class.is_empty() {
"note".to_string()
} else {
format!("note {note_class}")
};
s.push_str(&format!("<p class=\"{class}\">{}</p>", escape(note)));
}
s.push_str("</li>\n");
s
}
fn group(title: &str, rows: String) -> String {
if rows.is_empty() {
return String::new();
}
format!("<h3>{title}</h3>\n<ul class=\"nodes\">\n{rows}</ul>\n")
}
fn since_line(since: &Boundary, stops: usize) -> String {
match since {
Boundary::Beginning {
asked_for_manual: false,
} => "Since the beginning: no stops yet.".to_string(),
Boundary::Beginning {
asked_for_manual: true,
} => "Since the beginning: no stop here was made by hand.".to_string(),
Boundary::Stop { vivac, .. } => {
let date = crate::clock::date_of(&vivac.ts);
let tail = match stops {
0 => String::new(),
1 => ", 1 stop since".to_string(),
n => format!(", {n} stops since"),
};
format!(
"Since {}, the last stop you made, {date}{tail}.",
escape(&vivac.alias())
)
}
}
}
fn moved_section(project: &str, tree: &Tree, changed: &Changed) -> String {
let mut body = String::new();
body.push_str(&group(
"Opened",
changed
.opened
.iter()
.map(|n| row(project, tree, n, "", ""))
.collect(),
));
body.push_str(&group(
"Closed",
changed
.closed
.iter()
.map(|c| {
let note = if c.forced && c.outcome.is_empty() {
"forced".to_string()
} else if c.forced {
format!("forced: {}", c.outcome)
} else {
c.outcome.clone()
};
row(
project,
tree,
c.node,
¬e,
if c.forced { "forced" } else { "" },
)
})
.collect(),
));
body.push_str(&group(
"Flagged",
changed
.flagged
.iter()
.map(|f| {
let note = if f.reason.is_empty() {
f.flag.word().to_string()
} else {
format!("{}: {}", f.flag.word(), f.reason)
};
row(project, tree, f.node, ¬e, "flag")
})
.collect(),
));
body.push_str(&group(
"Moved",
changed
.moved
.iter()
.map(|m| row(project, tree, m.node, m.state.word(m.node.kind), ""))
.collect(),
));
if let Some(tail) = changes::tail_phrase(&changed.tail) {
body.push_str(&format!("<p class=\"note\">{}</p>\n", escape(&tail)));
}
if body.is_empty() {
body.push_str("<p class=\"empty\">Nothing has moved.</p>\n");
}
format!(
"<section id=\"moved\">\n<h2>What moved</h2>\n<p class=\"since\">{}</p>\n{body}</section>\n",
since_line(&changed.since, changed.tail.stops)
)
}
fn stack_section(project: &str, tree: &Tree) -> String {
let stack: Vec<&Node> = tree
.stack
.iter()
.filter_map(|&num| tree.node_by_num(num))
.collect();
if stack.is_empty() {
return "<section id=\"focus\">\n<h2>Where you are</h2>\n\
<p class=\"empty\">Empty stack.</p>\n</section>\n"
.to_string();
}
let last = stack.len() - 1;
let items: String = stack
.iter()
.enumerate()
.map(|(i, n)| {
let here = if i == last {
"<span class=\"here-mark\">you are here</span>"
} else {
""
};
format!(
"<li{}><span class=\"alias\">{}</span><p class=\"title\">{}{here}</p></li>\n",
if i == last { " class=\"here\"" } else { "" },
alias_link(project, &n.alias()),
escape(n.title(tree))
)
})
.collect();
format!(
"<section id=\"focus\">\n<h2>Where you are</h2>\n<ol class=\"stack\">\n{items}</ol>\n</section>\n"
)
}
fn governs_section(project: &str, tree: &Tree) -> String {
let focus = tree.focus();
let lineage: Vec<&Node> = focus.map(|f| tree.ancestors(f.num)).unwrap_or_default();
let on_lineage: std::collections::HashSet<u64> = lineage.iter().map(|n| n.num).collect();
let decisions: String = match focus {
Some(f) => crate::brief::standing(tree, f, &on_lineage)
.iter()
.map(|n| row(project, tree, n, "", ""))
.collect(),
None => String::new(),
};
let invariants: String = crate::brief::constraints(tree, &lineage)
.iter()
.map(|n| {
row(
project,
tree,
n,
if n.flags.is_empty() { "" } else { "at risk" },
"flag",
)
})
.collect();
let mut body = String::new();
body.push_str(&group("Standing decisions", decisions));
body.push_str(&group("Invariants", invariants));
if body.is_empty() {
body.push_str("<p class=\"empty\">Nothing governs this point yet.</p>\n");
}
format!("<section id=\"governs\">\n<h2>What governs this point</h2>\n{body}</section>\n")
}
fn parked_section(project: &str, tree: &Tree) -> String {
let mut ps: Vec<&Node> = tree
.nodes_iter()
.filter(|n| n.state == State::Suspended)
.collect();
ps.sort_by_key(|n| n.num);
let body = if ps.is_empty() {
"<p class=\"empty\">Nothing parked.</p>\n".to_string()
} else {
format!(
"<ul class=\"nodes\">\n{}</ul>\n",
ps.iter()
.map(|n| row(project, tree, n, n.outcome(tree), ""))
.collect::<String>()
)
};
format!("<section id=\"parked\">\n<h2>Do not touch now</h2>\n{body}</section>\n")
}
pub(super) fn today_page(project: &str, name: &str, tree: &Tree, log: &[Event]) -> String {
let boundary = changes::manual_boundary(tree);
let mut changed = changes::collect(tree, log, boundary.seq());
changed.since = boundary;
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>Today - {name_t}</title>\n\
<style>\n{WEB_CSS}</style></head>\n\
<body><div class=\"page\">\n\
<header><h1>{name_t}</h1>\n\
<p class=\"promise\">What moved while you were not looking.</p>\n\
<p class=\"onward\"><a href=\"/p/{p}/tree\">The whole tree, as a map</a></p></header>\n\
<main>\n{moved}{focus}{governs}{parked}</main>\n\
<footer>The same reading in a terminal: \
<code>vivac changes --since manual</code></footer>\n\
</div></body></html>\n",
name_t = escape(name),
p = escape(project),
moved = moved_section(project, tree, &changed),
focus = stack_section(project, tree),
governs = governs_section(project, tree),
parked = parked_section(project, tree),
)
}
#[cfg(test)]
mod tests {
use super::today_page;
use crate::event::{Body, Event, Kind, VivacKind};
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, title: &str, parent: Option<&str>) -> Event {
ev(
seq,
Body::NodeCreated {
node: format!("n{num}"),
num,
kind: Kind::Goal,
title: title.to_string(),
why: "it is needed".to_string(),
parent: parent.map(str::to_string),
blocks: false,
refs: vec![],
governs: vec![],
},
)
}
fn stop(seq: u64, num: u64, kind: VivacKind) -> Event {
ev(
seq,
Body::VivacCreated {
vivac: format!("v{num}"),
num,
kind,
stack: vec![],
working_set: vec![],
next_intent: String::new(),
anchor: crate::anchor::AnchorRef::default(),
node_ref: None,
label: String::new(),
},
)
}
#[test]
fn a_title_that_looks_like_markup_reaches_the_page_escaped() {
let events = vec![born(1, 1, "<script>alert(1)</script>", None)];
let tree = fold(&events, 0);
let page = today_page("demo", "demo", &tree, &events);
assert!(!page.contains("<script>alert(1)"), "{page}");
assert!(page.contains("<script>"), "{page}");
}
#[test]
fn what_moved_comes_before_the_rest() {
let events = vec![born(1, 1, "A goal", None)];
let tree = fold(&events, 0);
let page = today_page("demo", "demo", &tree, &events);
let at = |id: &str| page.find(id).unwrap_or_else(|| panic!("no {id}:\n{page}"));
assert!(at("id=\"moved\"") < at("id=\"focus\""), "{page}");
assert!(at("id=\"focus\"") < at("id=\"governs\""), "{page}");
assert!(at("id=\"governs\"") < at("id=\"parked\""), "{page}");
}
#[test]
fn the_moved_block_is_there_even_when_nothing_moved() {
let events = vec![born(1, 1, "A goal", None), stop(2, 1, VivacKind::Manual)];
let tree = fold(&events, 0);
let page = today_page("demo", "demo", &tree, &events);
assert!(page.contains("id=\"moved\""), "{page}");
assert!(page.contains("the last stop you made"), "{page}");
assert!(page.contains("Nothing has moved."), "{page}");
}
#[test]
fn the_boundary_is_the_last_stop_made_by_hand() {
let events = vec![
born(1, 1, "A goal", None),
stop(2, 1, VivacKind::Manual),
born(3, 2, "Born after the stop", Some("n1")),
stop(4, 2, VivacKind::Auto),
];
let tree = fold(&events, 0);
let page = today_page("demo", "demo", &tree, &events);
assert!(page.contains("Born after the stop"), "{page}");
assert!(page.contains("1 stop since"), "{page}");
}
#[test]
fn a_state_is_carried_by_a_word_and_not_only_by_a_class() {
let events = vec![
born(1, 1, "A goal", None),
born(2, 2, "Parked work", Some("n1")),
ev(
3,
Body::StateChanged {
node: "n2".to_string(),
state: crate::event::State::Suspended,
outcome: "waiting on day 14".to_string(),
forced: false,
},
),
];
let tree = fold(&events, 0);
let page = today_page("demo", "demo", &tree, &events);
assert!(page.contains("Do not touch now"), "{page}");
assert!(page.contains("waiting on day 14"), "{page}");
assert!(page.contains("parked"), "{page}");
}
#[test]
fn the_page_asks_for_nothing_off_this_machine() {
let events = vec![born(1, 1, "A goal", None)];
let tree = fold(&events, 0);
let page = today_page("demo", "demo", &tree, &events);
assert!(!page.contains("http://"), "{page}");
assert!(!page.contains("https://"), "{page}");
assert!(!page.contains("//fonts."), "{page}");
}
}