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

nordisk/facett

facett-timeline/src/model.rs

//! The **engine-agnostic, normalized timeline model** — the neutral shape a host
//! (knut / nornir viz) maps a Spark **jobs event timeline**, a **per-stage task
//! timeline**, or a **Lakeflow run Gantt** onto, and the
//! [`TimelineView`](crate::TimelineView) renders. Pure + egui-free: inject lanes
//! and bars, assert the laid-out fractions, no painter required.
//!
//! ## Shape (mirrors the `facett-pipeline` adapter seam)
//!
//! ```text
//! TimelineModel { lanes, t0, t1 }     // wall-clock window
//! Lane  { label, bars }               // e.g. "executor-3", "stage 7"
//! Bar   { id, label, start, end,      // positioned by wall-clock time
//!         status, segments }          // optional intra-bar phases
//! Segment { kind, fraction }          // scheduler-delay / compute / shuffle …
//! ```
//!
//! Nothing here depends on Spark: the model speaks `String` labels, `f64`
//! wall-clock values, a neutral [`BarStatus`], and an open [`Segment`] phase bag.
//! The host adapter is the only place that knows what a "stage" is.

use serde::{Deserialize, Serialize};

/// Neutral run status of a single bar (a job / stage / task / run). The host maps
/// its engine status onto this; the view maps it onto a [`Theme`](facett_core::Theme)
/// colour. Identical in spirit to `facett-pipeline`'s `StageStatus`, kept local so
/// this crate needs no pipeline dependency.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
pub enum BarStatus {
    /// Not started / queued.
    #[default]
    Idle,
    /// Currently executing.
    Running,
    /// Completed successfully.
    Done,
    /// Failed.
    Failed,
}

impl BarStatus {
    /// A short, stable label for text/JSON.
    pub fn label(self) -> &'static str {
        match self {
            BarStatus::Idle => "idle",
            BarStatus::Running => "running",
            BarStatus::Done => "done",
            BarStatus::Failed => "failed",
        }
    }
}

/// The canonical Spark **task-phase** order (the `TIMELINE_LEGEND` of
/// `StagePage.scala`): where a task's wall-clock time actually goes. A host fills a
/// [`Bar`]'s [`segments`](Bar::segments) with these `kind`s (or any free string);
/// the view colours known phases from a stable per-phase ramp and hashes the rest.
pub const TASK_PHASES: &[&str] = &[
    "scheduler_delay",
    "deserialize",
    "shuffle_read",
    "compute",
    "shuffle_write",
    "serialize",
    "getting_result",
];

/// An intra-bar **phase segment** — a fraction of the bar's width, coloured by its
/// `kind`. The fractions of a bar's segments are interpreted in order; they need
/// not sum to exactly 1 (the view normalises by their sum), so a host can hand raw
/// per-phase millis as fractions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Segment {
    /// Phase name (one of [`TASK_PHASES`], or any host string).
    pub kind: String,
    /// Relative weight of this phase within the bar (≥ 0).
    pub fraction: f32,
}

impl Segment {
    /// A phase segment with a relative weight.
    pub fn new(kind: impl Into<String>, fraction: f32) -> Self {
        Self { kind: kind.into(), fraction: fraction.max(0.0) }
    }
}

/// One **bar** on a lane: positioned + sized by the wall-clock window `[start,end]`,
/// optionally split into [`Segment`] phases.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Bar {
    /// Stable identity (selection is keyed on this, FC-5 — not a list index).
    pub id: String,
    /// Display label (shown in/over the bar + the hover tooltip).
    pub label: String,
    /// Wall-clock start.
    pub start: f64,
    /// Wall-clock end (clamped to `>= start`).
    pub end: f64,
    /// Run status → bar colour.
    pub status: BarStatus,
    /// Optional intra-bar phase segments (empty = a solid status-coloured bar).
    pub segments: Vec<Segment>,
}

impl Bar {
    /// A solid bar `[start,end]` with a status; id defaults to the label.
    pub fn new(label: impl Into<String>, start: f64, end: f64, status: BarStatus) -> Self {
        let label = label.into();
        Self { id: label.clone(), label, start, end: end.max(start), status, segments: Vec::new() }
    }
    /// Set a distinct stable id (when it differs from the display label).
    pub fn with_id(mut self, id: impl Into<String>) -> Self {
        self.id = id.into();
        self
    }
    /// Attach intra-bar phase segments.
    pub fn with_segments(mut self, segments: Vec<Segment>) -> Self {
        self.segments = segments;
        self
    }
    /// Wall-clock duration (`>= 0`).
    pub fn duration(&self) -> f64 {
        (self.end - self.start).max(0.0)
    }
    /// The sum of segment fractions (the normaliser; `0` when no segments).
    pub fn segment_total(&self) -> f32 {
        self.segments.iter().map(|s| s.fraction).sum()
    }
}

/// A named **swimlane** holding a row of bars (an executor's lifecycle, a stage's
/// tasks, a job track…).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Lane {
    /// Lane label (drawn in the pinned left gutter).
    pub label: String,
    /// The bars on this lane.
    pub bars: Vec<Bar>,
}

impl Lane {
    /// An empty lane with a label.
    pub fn new(label: impl Into<String>) -> Self {
        Self { label: label.into(), bars: Vec::new() }
    }
    /// Builder: append a bar.
    pub fn with_bar(mut self, bar: Bar) -> Self {
        self.bars.push(bar);
        self
    }
    /// Builder: set all bars.
    pub fn with_bars(mut self, bars: Vec<Bar>) -> Self {
        self.bars = bars;
        self
    }
}

/// How the time axis labels its ticks.
///
/// The timeline's `t0`/`t1` are plain `f64` wall-clock — deliberately unit-free,
/// so a host can mean seconds, millis, or anything monotonic. This says how to
/// *render* a tick, without the widget taking a date dependency: the calendar
/// conversion below is pure integer arithmetic.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AxisFormat {
    /// Raw number (`{t:.0}`) — the default, and what every existing host gets.
    #[default]
    Number,
    /// Treat the value as **epoch seconds** and label the tick `"Mon YY"`
    /// (e.g. `Jan 25`) — the calendar axis a case/handover timeline wants.
    EpochSecondsMonth,
}

impl AxisFormat {
    /// Render one tick value under this format.
    pub fn label(self, t: f64) -> String {
        match self {
            AxisFormat::Number => format!("{t:.0}"),
            AxisFormat::EpochSecondsMonth => {
                const M: [&str; 12] = [
                    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
                    "Dec",
                ];
                // Floor-divide so pre-epoch instants land on the right day.
                let days = (t / 86_400.0).floor() as i64;
                let (y, m, _d) = civil_from_days(days);
                format!("{} {:02}", M[(m - 1) as usize], y.rem_euclid(100))
            }
        }
    }
}

/// Days-since-epoch → `(year, month, day)`. Howard Hinnant's `civil_from_days`,
/// exact for the whole range and dependency-free — the reason this crate still
/// needs no date library.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
    (if m <= 2 { y + 1 } else { y }, m, d)
}

/// The whole **timeline**: stacked lanes over a shared wall-clock window `[t0,t1]`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TimelineModel {
    /// The swimlanes, top to bottom.
    pub lanes: Vec<Lane>,
    /// Window start (left edge of the time axis).
    pub t0: f64,
    /// Window end (right edge of the time axis).
    pub t1: f64,
    /// Optional **now marker**: a wall-clock instant drawn as a vertical rule
    /// across the lanes. `None` (the default) draws nothing, so every existing
    /// host is unaffected.
    ///
    /// The widget NEVER reads the clock — "now" is host-supplied, which keeps the
    /// view a pure function of its state and lets a test pin the marker.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub now: Option<f64>,
    /// How the time axis labels its ticks. Defaults to [`AxisFormat::Number`], so
    /// existing hosts render exactly as before.
    #[serde(default, skip_serializing_if = "is_number_axis")]
    pub axis: AxisFormat,
}

fn is_number_axis(a: &AxisFormat) -> bool {
    *a == AxisFormat::Number
}

impl TimelineModel {
    /// An empty timeline over `[t0,t1]`.
    pub fn new(t0: f64, t1: f64) -> Self {
        Self { lanes: Vec::new(), t0, t1, now: None, axis: AxisFormat::default() }
    }

    /// Builder: label the time axis with `axis` (see [`AxisFormat`]).
    pub fn with_axis(mut self, axis: AxisFormat) -> Self {
        self.axis = axis;
        self
    }

    /// Builder: mark `now` with a vertical rule (host-supplied; see [`now`](Self::now)).
    pub fn with_now(mut self, now: f64) -> Self {
        self.now = Some(now);
        self
    }
    /// Builder: append a lane.
    pub fn with_lane(mut self, lane: Lane) -> Self {
        self.lanes.push(lane);
        self
    }
    /// The wall-clock span of the window (never `0`, so fractions are finite).
    pub fn span(&self) -> f64 {
        (self.t1 - self.t0).max(f64::EPSILON)
    }
    /// Total bars across all lanes.
    pub fn bar_count(&self) -> usize {
        self.lanes.iter().map(|l| l.bars.len()).sum()
    }
    /// `true` when there is nothing to draw.
    pub fn is_empty(&self) -> bool {
        self.bar_count() == 0
    }

    /// Recompute `[t0,t1]` to tightly fit the min start / max end across every bar
    /// (a host that doesn't know the window up front calls this). No-op when empty.
    pub fn auto_window(&mut self) {
        let mut lo = f64::INFINITY;
        let mut hi = f64::NEG_INFINITY;
        for lane in &self.lanes {
            for b in &lane.bars {
                lo = lo.min(b.start);
                hi = hi.max(b.end);
            }
        }
        if lo.is_finite() && hi.is_finite() {
            self.t0 = lo;
            self.t1 = hi;
        }
    }

    /// The fraction `[0,1]` of the window at which wall-clock `t` sits (clamped).
    pub fn frac_for_time(&self, t: f64) -> f32 {
        self.frac_for_time_in(t, self.t0, self.t1)
    }

    /// The fraction `[0,1]` of an **arbitrary** window `[t0,t1]` at which wall-clock
    /// `t` sits (clamped). Lets the view render against a **zoom** window narrower
    /// than the model's own `[t0,t1]` while reusing one offset formula.
    pub fn frac_for_time_in(&self, t: f64, t0: f64, t1: f64) -> f32 {
        let span = (t1 - t0).max(f64::EPSILON);
        (((t - t0) / span) as f32).clamp(0.0, 1.0)
    }

    /// A bar's `(start_fraction, width_fraction)` within the model window — the single
    /// source of truth shared by the painter, the headless [`layout`](crate::layout)
    /// helper, and `state_json`, so what a test asserts IS what the pixels come from.
    pub fn bar_fraction(&self, bar: &Bar) -> (f32, f32) {
        self.bar_fraction_in(bar, self.t0, self.t1)
    }

    /// A bar's `(start_fraction, width_fraction)` within an **arbitrary** window
    /// `[t0,t1]` — the zoom-aware form of [`bar_fraction`](Self::bar_fraction).
    pub fn bar_fraction_in(&self, bar: &Bar, t0: f64, t1: f64) -> (f32, f32) {
        let s = self.frac_for_time_in(bar.start, t0, t1);
        let e = self.frac_for_time_in(bar.end, t0, t1);
        (s, (e - s).max(0.0))
    }

    /// The observable state of the model over its own `[t0,t1]` (folded into the
    /// view's `state_json`): the window + every lane's bars with their laid-out
    /// fractions + segments.
    pub fn state_json(&self) -> serde_json::Value {
        self.state_json_windowed(self.t0, self.t1)
    }

    /// The observable state computed against an **arbitrary** window `[t0,t1]` — the
    /// zoom-aware form the view dumps so a headless driver reads the bar offsets the
    /// *current zoom* paints (not the model's full span).
    pub fn state_json_windowed(&self, t0: f64, t1: f64) -> serde_json::Value {
        let lanes: Vec<serde_json::Value> = self
            .lanes
            .iter()
            .enumerate()
            .map(|(li, lane)| {
                let bars: Vec<serde_json::Value> = lane
                    .bars
                    .iter()
                    .map(|b| {
                        let (sf, wf) = self.bar_fraction_in(b, t0, t1);
                        let segs: Vec<serde_json::Value> = b
                            .segments
                            .iter()
                            .map(|s| serde_json::json!({ "kind": s.kind, "fraction": s.fraction }))
                            .collect();
                        serde_json::json!({
                            "id": b.id,
                            "label": b.label,
                            "start": b.start,
                            "end": b.end,
                            "start_frac": sf,
                            "width_frac": wf,
                            "status": b.status.label(),
                            "segments": segs,
                        })
                    })
                    .collect();
                serde_json::json!({ "lane": li, "label": lane.label, "bars": bars })
            })
            .collect();
        serde_json::json!({
            "t0": t0,
            "t1": t1,
            "span": (t1 - t0).max(f64::EPSILON),
            "model_t0": self.t0,
            "model_t1": self.t1,
            "lane_count": self.lanes.len(),
            "bar_count": self.bar_count(),
            "lanes": lanes,
        })
    }
}

#[cfg(test)]
mod tests {
    /// The default axis is unchanged for every existing host, and stays out of
    /// the serialized state.
    #[test]
    fn default_axis_is_raw_numbers_and_omitted_from_json() {
        let m = TimelineModel::new(0.0, 10.0);
        assert_eq!(m.axis, AxisFormat::Number);
        assert_eq!(m.axis.label(1_700_000_000.0), "1700000000");
        let j = serde_json::to_value(&m).expect("serializes");
        assert!(j.get("axis").is_none(), "default axis must not appear: {j}");
    }

    /// The calendar axis, against dates computed independently of the
    /// implementation. Covers the epoch itself, a known modern instant, a
    /// month-boundary, a leap day, and a PRE-epoch instant (the floor-division
    /// case a naive `as i64` truncation gets wrong).
    #[test]
    fn epoch_seconds_month_axis_labels_known_dates() {
        let f = AxisFormat::EpochSecondsMonth;
        assert_eq!(f.label(0.0), "Jan 70", "the epoch itself");
        // 2026-07-20T00:00:00Z = 1784505600
        assert_eq!(f.label(1_784_505_600.0), "Jul 26");
        // 2000-03-01T00:00:00Z = 951868800 (day after the 2000 leap day)
        assert_eq!(f.label(951_868_800.0), "Mar 00");
        // 2000-02-29T00:00:00Z = 951782400 — the leap day itself
        assert_eq!(f.label(951_782_400.0), "Feb 00");
        // 1969-12-31T00:00:00Z = -86400 — pre-epoch must floor, not truncate
        assert_eq!(f.label(-86_400.0), "Dec 69");
        // A fraction of a day past midnight stays on the same day.
        assert_eq!(f.label(1_784_505_600.0 + 3_600.0), "Jul 26");
    }

    /// Year-mod-100 must not go negative for pre-2000 dates (rem_euclid, not %).
    #[test]
    fn pre_2000_years_render_two_digits() {
        let f = AxisFormat::EpochSecondsMonth;
        // 1970-01-01 -> "70", not "-30" or similar.
        assert_eq!(f.label(0.0), "Jan 70");
        assert!(!f.label(-86_400.0).contains('-'), "no negative year digits");
    }

    /// The now marker is opt-in: absent by default, so every existing host is
    /// unaffected and the key stays out of the serialized state entirely.
    #[test]
    fn now_marker_is_absent_by_default_and_omitted_from_json() {
        let m = TimelineModel::new(0.0, 10.0);
        assert_eq!(m.now, None);
        let j = serde_json::to_value(&m).expect("model serializes");
        assert!(
            j.get("now").is_none(),
            "an unset marker must not appear in the state at all: {j}"
        );
    }

    /// Set via the builder, it round-trips through the state a robot asserts.
    #[test]
    fn now_marker_round_trips_when_set() {
        let m = TimelineModel::new(0.0, 10.0).with_now(4.5);
        assert_eq!(m.now, Some(4.5));
        let j = serde_json::to_value(&m).expect("model serializes");
        assert_eq!(j["now"], 4.5);
        let back: TimelineModel = serde_json::from_value(j).expect("model round-trips");
        assert_eq!(back.now, Some(4.5));
    }

    use super::*;

    #[test]
    fn bar_fractions_map_the_window() {
        let m = TimelineModel::new(0.0, 1000.0)
            .with_lane(Lane::new("e1").with_bar(Bar::new("a", 0.0, 300.0, BarStatus::Done)))
            .with_lane(Lane::new("e2").with_bar(Bar::new("b", 500.0, 1000.0, BarStatus::Running)));
        let (s0, w0) = m.bar_fraction(&m.lanes[0].bars[0]);
        assert!((s0 - 0.0).abs() < 1e-5 && (w0 - 0.3).abs() < 1e-5, "0..300 → start 0 width .3");
        let (s1, w1) = m.bar_fraction(&m.lanes[1].bars[0]);
        assert!((s1 - 0.5).abs() < 1e-5 && (w1 - 0.5).abs() < 1e-5, "500..1000 → start .5 width .5");
        assert_eq!(m.bar_count(), 2);
    }

    #[test]
    fn auto_window_fits_all_bars() {
        let mut m = TimelineModel::new(0.0, 1.0)
            .with_lane(Lane::new("e").with_bars(vec![
                Bar::new("a", 120.0, 300.0, BarStatus::Done),
                Bar::new("b", 50.0, 880.0, BarStatus::Running),
            ]));
        m.auto_window();
        assert_eq!((m.t0, m.t1), (50.0, 880.0));
    }

    #[test]
    fn state_json_carries_lanes_bars_and_segments() {
        let m = TimelineModel::new(0.0, 100.0).with_lane(
            Lane::new("tasks").with_bar(
                Bar::new("t0", 0.0, 100.0, BarStatus::Running)
                    .with_segments(vec![Segment::new("compute", 3.0), Segment::new("shuffle_write", 1.0)]),
            ),
        );
        let j = m.state_json();
        assert_eq!(j["bar_count"], 1);
        assert_eq!(j["lanes"][0]["bars"][0]["segments"].as_array().unwrap().len(), 2);
        assert_eq!(j["lanes"][0]["bars"][0]["status"], "running");
    }
}