Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
    }
}