facett-gantt/src/lib.rs
//! **facett-gantt** — a Gantt chart facet for ordning.
//!
//! Tasks as bars over a time axis, one row each, with **dependency arrows**
//! (`blocks` / `depends-on`) drawn between them and a **progress** fill. Derives
//! naturally from the case's stage/task DAG (the funnel edges) + dates. Useful
//! for planning an ärende's handläggning or a project's work.
//!
//! Design: `ordning/.nornir/widgets/facett-gantt.md`. Built in ordning;
//! **destined for facett**. Pure egui + chrono; deterministic.
use chrono::{Datelike, NaiveDate};
use egui::{Color32, Pos2, Rect, Sense, Stroke, Ui, Vec2};
use serde::{Deserialize, Serialize};
/// A Gantt task (one row).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub label: String,
pub start: NaiveDate,
pub end: NaiveDate,
/// Progress 0.0..=1.0 (drawn as an inner fill).
pub progress: f32,
/// Ids of tasks this one depends on (an arrow is drawn dep → this).
pub deps: Vec<String>,
/// True for a milestone (drawn as a diamond, zero-width).
pub milestone: bool,
}
impl Task {
pub fn new(id: impl Into<String>, label: impl Into<String>, start: NaiveDate, end: NaiveDate) -> Self {
Self { id: id.into(), label: label.into(), start, end, progress: 0.0, deps: Vec::new(), milestone: false }
}
pub fn dep(mut self, id: impl Into<String>) -> Self {
self.deps.push(id.into());
self
}
pub fn progress(mut self, p: f32) -> Self {
self.progress = p.clamp(0.0, 1.0);
self
}
}
pub struct Gantt {
title: String,
tasks: Vec<Task>,
range: Option<(NaiveDate, NaiveDate)>,
today: Option<NaiveDate>,
selected: Option<usize>,
}
const GUTTER: f32 = 170.0;
const AXIS_H: f32 = 22.0;
const ROW_H: f32 = 28.0;
impl Gantt {
pub fn new(title: impl Into<String>, tasks: Vec<Task>) -> Self {
Self { title: title.into(), tasks, range: None, today: None, selected: None }
}
pub fn set_tasks(&mut self, tasks: Vec<Task>) {
self.tasks = tasks;
self.selected = None;
}
pub fn with_today(mut self, today: NaiveDate) -> Self {
self.today = Some(today);
self
}
pub fn selected_task(&self) -> Option<&str> {
self.selected.and_then(|i| self.tasks.get(i)).map(|t| t.id.as_str())
}
/// Select a task **by id** — the headless/robot counterpart of clicking its
/// bar. Returns `true` when the id resolved (an unknown id is a no-op, so a
/// robot script asserting `responds_to_input` cannot silently pass on a typo).
pub fn select_id(&mut self, id: &str) -> bool {
match self.tasks.iter().position(|t| t.id == id) {
Some(i) => {
self.selected = Some(i);
true
}
None => false,
}
}
/// Clear the selection.
pub fn clear_selection(&mut self) {
self.selected = None;
}
/// The **shared demo fixture** — one board that exercises every render branch
/// (a progress bar, a dependency chain, a milestone diamond, a `today`
/// marker inside the range). Consumed by the robot/matrix tests and by hosts
/// that want a populated pane without inventing data (L5: one fixture, not
/// one per test file).
pub fn local() -> Self {
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
let mut launch = Task::new("launch", "Launch", d(2026, 6, 18), d(2026, 6, 18));
launch.milestone = true;
launch.deps.push("beslut".into());
Self::new(
"Bygglov",
vec![
Task::new("utred", "Utredning", d(2026, 6, 1), d(2026, 6, 8)).progress(1.0),
Task::new("komm", "Kommunicering", d(2026, 6, 8), d(2026, 6, 15))
.dep("utred")
.progress(0.5),
Task::new("beslut", "Beslut", d(2026, 6, 15), d(2026, 6, 18)).dep("komm"),
launch,
],
)
.with_today(d(2026, 6, 11))
}
fn effective_range(&self) -> (NaiveDate, NaiveDate) {
if let Some(r) = self.range {
return r;
}
let lo = self.tasks.iter().map(|t| t.start).min();
let hi = self.tasks.iter().map(|t| t.end).max();
match (lo, hi) {
(Some(lo), Some(hi)) if hi > lo => (lo, hi),
(Some(lo), Some(_)) => (lo, lo + chrono::Duration::days(1)),
_ => {
let z = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
(z, z + chrono::Duration::days(30))
}
}
}
/// Tasks whose end-date is on the longest dependency chain (a simple
/// **critical path** proxy: the chain with the latest finish). Returns the
/// set of task ids on that chain.
pub fn critical_path(&self) -> Vec<String> {
use std::collections::HashMap;
let by_id: HashMap<&str, &Task> = self.tasks.iter().map(|t| (t.id.as_str(), t)).collect();
// memoized longest finish-date chain ending at each task
let mut best_pred: HashMap<&str, Option<&str>> = HashMap::new();
let mut finish: HashMap<&str, NaiveDate> = HashMap::new();
// process in a stable order; iterate to a fixpoint (DAG, so it settles)
for _ in 0..self.tasks.len().max(1) {
for t in &self.tasks {
let mut f = t.end;
let mut pred = None;
for dep in &t.deps {
if let Some(&de) = finish.get(dep.as_str()) {
// a chain through dep finishing later wins
let candidate = de.max(t.end);
if candidate >= f {
f = candidate;
pred = Some(dep.as_str());
}
} else if by_id.contains_key(dep.as_str()) {
pred = pred.or(Some(dep.as_str()));
}
}
finish.insert(t.id.as_str(), f);
best_pred.insert(t.id.as_str(), pred);
}
}
// The task with the latest finish ends the critical path.
//
// DETERMINISM (bug fix): this used to scan `finish.iter()` — a HashMap —
// so when two tasks TIE on the finish date (the `beslut`/`launch` pair in
// `local()` both end 2026-06-18) the winner was picked by hash-iteration
// order and `critical_path()` returned a DIFFERENT chain from run to run
// for the SAME board. `state_json` therefore was not reproducible, which
// is exactly what the robot/matrix oracle reads. Scan `self.tasks` in
// declaration order instead: `max_by_key` keeps the LAST maximum, so a tie
// deterministically resolves to the task declared last (the downstream one,
// e.g. the `launch` milestone that depends on `beslut`).
let Some(end_id) = self
.tasks
.iter()
.filter_map(|t| finish.get(t.id.as_str()).map(|d| (t.id.as_str(), *d)))
.max_by_key(|(_, d)| *d)
.map(|(id, _)| id)
else {
return vec![];
};
let mut path = vec![end_id.to_string()];
let mut cur = best_pred.get(end_id).copied().flatten();
while let Some(p) = cur {
path.push(p.to_string());
cur = best_pred.get(p).copied().flatten();
}
path.reverse();
path
}
// ── facett Facet contract ────────────────────────────────────────────────
pub fn title(&self) -> &str {
&self.title
}
pub fn state_json(&self) -> serde_json::Value {
let (lo, hi) = self.effective_range();
serde_json::json!({
"title": self.title,
"range": [lo.to_string(), hi.to_string()],
"task_count": self.tasks.len(),
"selected": self.selected_task(),
"critical_path": self.critical_path(),
"tasks": self.tasks.iter().map(|t| serde_json::json!({
"id": t.id, "label": t.label,
"start": t.start.to_string(), "end": t.end.to_string(),
"progress": t.progress, "deps": t.deps, "milestone": t.milestone,
})).collect::<Vec<_>>(),
})
}
pub fn ui(&mut self, ui: &mut Ui) -> Option<String> {
ui.heading(&self.title);
ui.separator();
let mut clicked = None;
let (lo, hi) = self.effective_range();
let total = (hi - lo).num_days().max(1) as f32;
let crit: std::collections::HashSet<String> = self.critical_path().into_iter().collect();
let height = AXIS_H + self.tasks.len() as f32 * ROW_H + 8.0;
let width = ui.available_width().max(GUTTER + 200.0);
let (rect, _r) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 4.0, Color32::from_rgb(24, 26, 32));
let plot_left = rect.min.x + GUTTER;
let plot_w = rect.max.x - plot_left - 8.0;
let x_of = |d: NaiveDate| plot_left + ((d - lo).num_days() as f32 / total) * plot_w;
// axis month ticks
let mut tick = NaiveDate::from_ymd_opt(lo.year(), lo.month(), 1).unwrap();
while tick <= hi {
let x = x_of(tick.max(lo));
painter.line_segment(
[Pos2::new(x, rect.min.y + AXIS_H), Pos2::new(x, rect.max.y)],
Stroke::new(0.5_f32, Color32::from_rgb(46, 48, 56)),
);
painter.text(
Pos2::new(x + 3.0, rect.min.y + 3.0),
egui::Align2::LEFT_TOP,
month_label(tick),
egui::FontId::proportional(10.0),
Color32::from_rgb(150, 155, 165),
);
tick = add_month(tick);
}
if let Some(t) = self.today && t >= lo && t <= hi {
let x = x_of(t);
painter.line_segment(
[Pos2::new(x, rect.min.y), Pos2::new(x, rect.max.y)],
Stroke::new(1.5_f32, Color32::from_rgb(247, 198, 0)),
);
}
// row geometry first (so dependency arrows can find bar endpoints)
let row_y = |i: usize| rect.min.y + AXIS_H + i as f32 * ROW_H;
let idx_of: std::collections::HashMap<&str, usize> =
self.tasks.iter().enumerate().map(|(i, t)| (t.id.as_str(), i)).collect();
// dependency arrows: from dep's end to this task's start
for (i, t) in self.tasks.iter().enumerate() {
let sy = row_y(i) + ROW_H * 0.5;
let sx = x_of(t.start);
for dep in &t.deps {
if let Some(&di) = idx_of.get(dep.as_str()) {
let dep_end = x_of(self.tasks[di].end);
let dy = row_y(di) + ROW_H * 0.5;
let col = Color32::from_rgb(90, 95, 110);
painter.line_segment([Pos2::new(dep_end, dy), Pos2::new(dep_end + 6.0, dy)], Stroke::new(1.0_f32, col));
painter.line_segment([Pos2::new(dep_end + 6.0, dy), Pos2::new(dep_end + 6.0, sy)], Stroke::new(1.0_f32, col));
painter.line_segment([Pos2::new(dep_end + 6.0, sy), Pos2::new(sx, sy)], Stroke::new(1.0_f32, col));
}
}
}
// task rows
for (i, t) in self.tasks.iter().enumerate() {
let y = row_y(i);
painter.text(
Pos2::new(rect.min.x + 8.0, y + ROW_H * 0.5),
egui::Align2::LEFT_CENTER,
&t.label,
egui::FontId::proportional(12.0),
Color32::from_rgb(205, 210, 220),
);
let on_crit = crit.contains(&t.id);
let base = if on_crit { Color32::from_rgb(210, 110, 90) } else { Color32::from_rgb(90, 140, 200) };
if t.milestone {
// diamond
let cx = x_of(t.start);
let cy = y + ROW_H * 0.5;
let s = 6.0;
let pts = vec![
Pos2::new(cx, cy - s),
Pos2::new(cx + s, cy),
Pos2::new(cx, cy + s),
Pos2::new(cx - s, cy),
];
painter.add(egui::Shape::convex_polygon(pts, Color32::from_rgb(247, 198, 0), Stroke::NONE));
} else {
let x0 = x_of(t.start);
let x1 = x_of(t.end).max(x0 + 3.0);
let bar = Rect::from_min_max(Pos2::new(x0, y + 5.0), Pos2::new(x1, y + ROW_H - 5.0));
painter.rect_filled(bar, 3.0, base.gamma_multiply(0.5));
// progress fill
if t.progress > 0.0 {
let pr = Rect::from_min_max(bar.min, Pos2::new(x0 + bar.width() * t.progress, bar.max.y));
painter.rect_filled(pr, 3.0, base);
}
if self.selected == Some(i) {
painter.rect_stroke(bar, 3.0, Stroke::new(2.0_f32, Color32::WHITE), egui::StrokeKind::Outside);
}
let resp = ui.interact(bar, egui::Id::new(("gantt-bar", i)), Sense::click());
if resp.hovered() {
resp.clone().on_hover_text_at_pointer(format!(
"{}\n{} → {}\n{:.0}%{}",
t.label, t.start, t.end, t.progress * 100.0,
if on_crit { " • kritisk linje" } else { "" }
));
}
if resp.clicked() {
self.selected = Some(i);
clicked = Some(t.id.clone());
}
}
}
clicked
}
}
// ── The host-facing `Facet` contract ─────────────────────────────────────────
//
// The inherent `title`/`state_json`/`ui` trio above already IS the contract; this
// impl is the thin adapter that makes `Gantt` an object-safe `dyn Facet`, so it
// composes into a `FacetDeck` and — the point of this round — can be handed to
// `facett_core::testmatrix::probe_and_emit` for its robot matrix cell in ONE call
// instead of a bespoke per-crate harness. Additive: every existing caller of the
// inherent methods keeps compiling (the inherent `ui` still returns the clicked
// id; the trait's returns `()`).
impl facett_core::Facet for Gantt {
fn title(&self) -> &str {
Gantt::title(self)
}
fn ui(&mut self, ui: &mut Ui) {
let _clicked = Gantt::ui(self, ui);
}
fn state_json(&self) -> serde_json::Value {
Gantt::state_json(self)
}
/// The **robot drive seam**: `{"select":"<task id>"}` selects a task by id
/// (the headless twin of clicking its bar) and `"clear_selection"` drops it.
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
if v == serde_json::json!("clear_selection") {
self.clear_selection();
return;
}
if let Some(id) = v.get("select").and_then(|s| s.as_str()) {
self.select_id(id);
}
}
fn selection_json(&self) -> serde_json::Value {
match self.selected_task() {
Some(id) => serde_json::json!(id),
None => serde_json::Value::Null,
}
}
}
fn month_label(d: NaiveDate) -> String {
const M: [&str; 12] = ["jan","feb","mar","apr","maj","jun","jul","aug","sep","okt","nov","dec"];
format!("{} {}", M[(d.month() - 1) as usize], d.year() % 100)
}
fn add_month(d: NaiveDate) -> NaiveDate {
let (y, m) = if d.month() == 12 { (d.year() + 1, 1) } else { (d.year(), d.month() + 1) };
NaiveDate::from_ymd_opt(y, m, 1).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn g() -> Gantt {
Gantt::new("Bygglov", vec![
Task::new("utred", "Utredning", d(2026,6,1), d(2026,6,8)).progress(1.0),
Task::new("komm", "Kommunicering", d(2026,6,8), d(2026,6,15)).dep("utred").progress(0.5),
Task::new("beslut", "Beslut", d(2026,6,15), d(2026,6,18)).dep("komm"),
])
}
#[test]
fn critical_path_follows_dependency_chain() {
let cp = g().critical_path();
// longest chain ends at "beslut" (latest finish) via komm ← utred
assert_eq!(cp.last().unwrap(), "beslut");
assert!(cp.contains(&"komm".to_string()));
}
#[test]
fn state_json_has_tasks_and_range() {
let s = g().state_json();
assert_eq!(s["task_count"], 3);
assert_eq!(s["range"][0], "2026-06-01");
assert_eq!(s["range"][1], "2026-06-18");
assert!(s["critical_path"].as_array().unwrap().contains(&serde_json::json!("beslut")));
}
// ── critical_path edge cases ─────────────────────────────────────────────
#[test]
fn critical_path_empty_board_is_empty() {
let g = Gantt::new("empty", vec![]);
assert!(g.critical_path().is_empty());
}
#[test]
fn critical_path_milestone_only_board() {
// A single milestone task: it is the latest (only) finish, so the path is
// exactly that task — no panic on the zero-dep chain.
let mut ms = Task::new("launch", "Launch", d(2026, 7, 1), d(2026, 7, 1));
ms.milestone = true;
let g = Gantt::new("ms", vec![ms]);
assert_eq!(g.critical_path(), vec!["launch".to_string()]);
}
#[test]
fn critical_path_ignores_dangling_dep() {
// "beslut" depends on a task id that is NOT on the board. The dangling dep
// must not appear in the path and must not panic.
let t = Task::new("beslut", "Beslut", d(2026, 6, 15), d(2026, 6, 18)).dep("ghost");
let g = Gantt::new("dangle", vec![t]);
let cp = g.critical_path();
assert_eq!(cp, vec!["beslut".to_string()]);
assert!(!cp.contains(&"ghost".to_string()), "a non-task dep id never enters the path");
}
// ── effective_range fallbacks ────────────────────────────────────────────
#[test]
fn effective_range_empty_defaults_to_2026_window() {
let g = Gantt::new("empty", vec![]);
let (lo, hi) = g.effective_range();
assert_eq!(lo, d(2026, 1, 1));
assert_eq!(hi, d(2026, 1, 31));
}
#[test]
fn effective_range_single_point_extends_one_day() {
// A task whose start == end gives a zero-width window; the fallback nudges
// hi forward by one day so the axis math (`total.max(1)`) has span.
let t = Task::new("x", "X", d(2026, 6, 10), d(2026, 6, 10));
let g = Gantt::new("point", vec![t]);
let (lo, hi) = g.effective_range();
assert_eq!(lo, d(2026, 6, 10));
assert_eq!(hi, d(2026, 6, 11));
}
#[test]
fn effective_range_prefers_explicit_range() {
let mut g = g();
g.range = Some((d(2026, 1, 1), d(2026, 12, 31)));
assert_eq!(g.effective_range(), (d(2026, 1, 1), d(2026, 12, 31)));
}
// ── set_tasks / with_today ───────────────────────────────────────────────
#[test]
fn set_tasks_resets_selection() {
let mut g = g();
// Force a selection as the ui() click path would, then replace the tasks.
g.selected = Some(2);
assert_eq!(g.selected_task(), Some("beslut"));
g.set_tasks(vec![Task::new("solo", "Solo", d(2026, 6, 1), d(2026, 6, 2))]);
assert_eq!(g.selected_task(), None, "replacing tasks clears the stale selection index");
assert_eq!(g.state_json()["task_count"], 1);
}
#[test]
fn with_today_is_recorded() {
let near = g().with_today(d(2026, 6, 10));
assert_eq!(near.today, Some(d(2026, 6, 10)));
// A today outside the range is simply not drawn — but must not panic in ui.
let far = g().with_today(d(2030, 1, 1));
assert_eq!(far.today, Some(d(2030, 1, 1)));
}
#[test]
fn selected_task_out_of_bounds_is_none() {
let mut g = Gantt::new("t", vec![Task::new("a", "A", d(2026, 6, 1), d(2026, 6, 2))]);
g.selected = Some(9); // stale index past the end
assert_eq!(g.selected_task(), None);
}
}