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

nordisk/facett

facett-calendar/src/lib.rs

//! **facett-calendar** — a calendar facet for ordning.
//!
//! A **Monday-first** month grid (Swedish/ISO week) with a **date picker** and
//! **marked events** — case deadlines (handläggningstid/SLA due dates) and
//! **absence** (a handläggare's planned leave, which drives the handover model).
//! Navigate months; click a day to select it; days with events show a count and
//! the strongest event color.
//!
//! Design: `ordning/.nornir/widgets/facett-calendar.md`. Built in ordning
//! temporarily; **destined for facett** (implements the facett `Facet` shape).
//! Pure egui + chrono.

use chrono::{Datelike, NaiveDate};
use egui::{Color32, Sense, Ui, Vec2};
use serde::{Deserialize, Serialize};

/// What a marked day means (drives its color).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventKind {
    /// A case deadline / SLA due date.
    Deadline,
    /// A statutory frist (e.g. FL §12 six-month mark) — most urgent.
    Frist,
    /// Planned absence / leave (drives handover).
    Absence,
    /// A scheduled appointment / hearing / sammanträde.
    Appointment,
}

impl EventKind {
    fn color(self) -> Color32 {
        match self {
            EventKind::Frist => Color32::from_rgb(220, 70, 70),
            EventKind::Deadline => Color32::from_rgb(220, 150, 60),
            EventKind::Absence => Color32::from_rgb(140, 120, 210),
            EventKind::Appointment => Color32::from_rgb(90, 170, 160),
        }
    }
    fn rank(self) -> u8 {
        match self {
            EventKind::Frist => 3,
            EventKind::Deadline => 2,
            EventKind::Appointment => 1,
            EventKind::Absence => 0,
        }
    }
}

/// A marked day.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Event {
    pub date: NaiveDate,
    pub label: String,
    pub kind: EventKind,
    /// Optional case key this event belongs to.
    pub case: Option<String>,
}

/// The calendar facet.
pub struct Calendar {
    title: String,
    /// The displayed month (year, month 1..=12). The host sets the initial month
    /// (no implicit "today" so behavior is deterministic/testable); the user
    /// navigates with the prev/next controls.
    year: i32,
    month: u32,
    /// Optional "today" marker (host supplies it; the widget never reads the clock).
    today: Option<NaiveDate>,
    events: Vec<Event>,
    selected: Option<NaiveDate>,
}

const WEEKDAYS: [&str; 7] = ["Mån", "Tis", "Ons", "Tor", "Fre", "Lör", "Sön"];
const MONTHS: [&str; 12] = [
    "Januari", "Februari", "Mars", "April", "Maj", "Juni", "Juli", "Augusti", "September",
    "Oktober", "November", "December",
];

fn days_in_month(year: i32, month: u32) -> u32 {
    let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
    let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
    let next = NaiveDate::from_ymd_opt(ny, nm, 1).unwrap();
    next.signed_duration_since(first).num_days() as u32
}

impl Calendar {
    pub fn new(title: impl Into<String>, year: i32, month: u32) -> Self {
        Self {
            title: title.into(),
            year,
            month: month.clamp(1, 12),
            today: None,
            events: Vec::new(),
            selected: None,
        }
    }

    pub fn with_today(mut self, today: NaiveDate) -> Self {
        self.today = Some(today);
        self
    }

    pub fn set_events(&mut self, events: Vec<Event>) {
        self.events = events;
    }

    pub fn selected_date(&self) -> Option<NaiveDate> {
        self.selected
    }

    /// Select a day **by ISO date** — the headless/robot counterpart of clicking
    /// a day cell. Returns `false` (and selects nothing) for an unparseable date
    /// or one outside the displayed month, so a robot script with a bad date
    /// cannot fake a live input surface.
    pub fn select_date(&mut self, iso: &str) -> bool {
        let Ok(d) = iso.parse::<NaiveDate>() else { return false };
        if d.year() != self.year || d.month() != self.month {
            return false;
        }
        self.selected = Some(d);
        true
    }

    /// Clear the day selection.
    pub fn clear_selection(&mut self) {
        self.selected = None;
    }

    /// The **shared demo fixture** — June 2026 (1 June is a Monday → lead 0) with
    /// a two-event day (Deadline + Frist, so the marker paints a count) and an
    /// absence day, plus a `today` inside the month. Consumed by the robot and
    /// matrix tests (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 c = Self::new("Frister", 2026, 6).with_today(d(2026, 6, 11));
        c.set_events(vec![
            Event {
                date: d(2026, 6, 15),
                label: "Bygglov beslut".into(),
                kind: EventKind::Deadline,
                case: Some("BL-12".into()),
            },
            Event {
                date: d(2026, 6, 15),
                label: "Yttrande".into(),
                kind: EventKind::Frist,
                case: Some("ÖK-3".into()),
            },
            Event {
                date: d(2026, 6, 20),
                label: "Maria semester".into(),
                kind: EventKind::Absence,
                case: None,
            },
        ]);
        c
    }

    /// (year, month) currently displayed.
    pub fn displayed(&self) -> (i32, u32) {
        (self.year, self.month)
    }

    pub fn prev_month(&mut self) {
        if self.month == 1 {
            self.month = 12;
            self.year -= 1;
        } else {
            self.month -= 1;
        }
    }

    pub fn next_month(&mut self) {
        if self.month == 12 {
            self.month = 1;
            self.year += 1;
        } else {
            self.month += 1;
        }
    }

    fn events_on(&self, date: NaiveDate) -> impl Iterator<Item = &Event> {
        self.events.iter().filter(move |e| e.date == date)
    }

    /// The strongest event color on a day (by rank), if any.
    fn day_marker(&self, date: NaiveDate) -> Option<(Color32, usize)> {
        let mut best: Option<&Event> = None;
        let mut count = 0;
        for e in self.events_on(date) {
            count += 1;
            best = match best {
                Some(b) if b.kind.rank() >= e.kind.rank() => Some(b),
                _ => Some(e),
            };
        }
        best.map(|e| (e.kind.color(), count))
    }

    // ── facett Facet contract ────────────────────────────────────────────────

    pub fn title(&self) -> &str {
        &self.title
    }

    /// Headless-observable state: the displayed month, the selected day, and the
    /// marked days (date → kinds) — so a test asserts what the calendar shows
    /// without a display.
    pub fn state_json(&self) -> serde_json::Value {
        let marked: Vec<_> = self
            .events
            .iter()
            .map(|e| {
                serde_json::json!({
                    "date": e.date.to_string(),
                    "kind": format!("{:?}", e.kind),
                    "label": e.label,
                    "case": e.case,
                })
            })
            .collect();
        serde_json::json!({
            "title": self.title,
            "year": self.year,
            "month": self.month,
            "month_name": MONTHS[(self.month - 1) as usize],
            "selected": self.selected.map(|d| d.to_string()),
            "today": self.today.map(|d| d.to_string()),
            "events": marked,
        })
    }

    /// Paint one frame. Returns the date the user clicked this frame, if any.
    pub fn ui(&mut self, ui: &mut Ui) -> Option<NaiveDate> {
        let mut clicked = None;

        ui.horizontal(|ui| {
            if ui.button("◀").clicked() {
                self.prev_month();
            }
            ui.label(
                egui::RichText::new(format!("{} {}", MONTHS[(self.month - 1) as usize], self.year))
                    .strong(),
            );
            if ui.button("▶").clicked() {
                self.next_month();
            }
        });
        ui.separator();

        let first = NaiveDate::from_ymd_opt(self.year, self.month, 1).unwrap();
        // Monday-first offset: how many blank cells before day 1.
        let lead = first.weekday().num_days_from_monday() as usize;
        let ndays = days_in_month(self.year, self.month);

        egui::Grid::new(("facett-calendar", self.year, self.month))
            .num_columns(7)
            .spacing(Vec2::new(4.0, 4.0))
            .show(ui, |ui| {
                for wd in WEEKDAYS {
                    ui.label(egui::RichText::new(wd).small().color(Color32::GRAY));
                }
                ui.end_row();

                let mut col = 0;
                for _ in 0..lead {
                    ui.label("");
                    col += 1;
                }
                for day in 1..=ndays {
                    let date = NaiveDate::from_ymd_opt(self.year, self.month, day).unwrap();
                    if self.day_cell(ui, date) {
                        clicked = Some(date);
                    }
                    col += 1;
                    if col % 7 == 0 {
                        ui.end_row();
                    }
                }
            });

        if let Some(d) = clicked {
            self.selected = Some(d);
        }
        clicked
    }

    /// Returns true if this day was clicked.
    fn day_cell(&self, ui: &mut Ui, date: NaiveDate) -> bool {
        let is_selected = self.selected == Some(date);
        let is_today = self.today == Some(date);
        let marker = self.day_marker(date);

        let (rect, resp) = ui.allocate_exact_size(Vec2::new(34.0, 34.0), Sense::click());
        let painter = ui.painter_at(rect);

        let bg = if is_selected {
            Color32::from_rgb(60, 64, 76)
        } else if resp.hovered() {
            Color32::from_rgb(44, 47, 56)
        } else {
            Color32::from_rgb(32, 34, 40)
        };
        painter.rect_filled(rect, 4.0, bg);
        if is_today {
            painter.rect_filled(
                egui::Rect::from_min_size(rect.min, Vec2::new(rect.width(), 2.5)),
                0.0,
                Color32::from_rgb(247, 198, 0), // Tillsynia yellow accent for "today"
            );
        }
        // Day number.
        painter.text(
            rect.min + Vec2::new(5.0, 4.0),
            egui::Align2::LEFT_TOP,
            day_num(date),
            egui::FontId::proportional(12.0),
            Color32::from_rgb(210, 215, 225),
        );
        // Event marker: a colored dot + count at the bottom-right.
        if let Some((color, count)) = marker {
            painter.circle_filled(rect.max - Vec2::new(8.0, 8.0), 4.0, color);
            if count > 1 {
                painter.text(
                    rect.max - Vec2::new(8.0, 8.0),
                    egui::Align2::CENTER_CENTER,
                    format!("{count}"),
                    egui::FontId::proportional(8.0),
                    Color32::BLACK,
                );
            }
        }

        resp.on_hover_text_at_pointer(self.hover_text(date)).clicked()
    }

    fn hover_text(&self, date: NaiveDate) -> String {
        let evs: Vec<String> = self
            .events_on(date)
            .map(|e| format!("• {} [{:?}]", e.label, e.kind))
            .collect();
        if evs.is_empty() {
            date.to_string()
        } else {
            format!("{}\n{}", date, evs.join("\n"))
        }
    }
}

// ── The host-facing `Facet` contract ─────────────────────────────────────────
//
// The inherent `title`/`state_json`/`ui` trio above already IS the contract; this
// thin adapter makes `Calendar` an object-safe `dyn Facet` so it composes into a
// `FacetDeck` and can be handed to `facett_core::testmatrix::probe_and_emit` for
// its robot matrix cell in ONE call. Additive — the inherent `ui` still returns
// the clicked date.
impl facett_core::Facet for Calendar {
    fn title(&self) -> &str {
        Calendar::title(self)
    }
    fn ui(&mut self, ui: &mut Ui) {
        let _clicked = Calendar::ui(self, ui);
    }
    fn state_json(&self) -> serde_json::Value {
        Calendar::state_json(self)
    }
    /// The **robot drive seam**: `"next_month"` / `"prev_month"` walk the grid and
    /// `{"select":"2026-06-15"}` picks a day (the headless twin of a cell click);
    /// `"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 };
        match v.as_str() {
            Some("next_month") => return self.next_month(),
            Some("prev_month") => return self.prev_month(),
            Some("clear_selection") => return self.clear_selection(),
            _ => {}
        }
        if let Some(iso) = v.get("select").and_then(|s| s.as_str()) {
            self.select_date(iso);
        }
    }
    fn selection_json(&self) -> serde_json::Value {
        match self.selected_date() {
            Some(d) => serde_json::json!(d.to_string()),
            None => serde_json::Value::Null,
        }
    }
}

fn day_num(date: NaiveDate) -> String {
    date.day().to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cal() -> Calendar {
        // June 2026: 1 June 2026 is a Monday (lead = 0); 30 days.
        let mut c = Calendar::new("Frister", 2026, 6).with_today(d(2026, 6, 11));
        c.set_events(vec![
            Event { date: d(2026, 6, 15), label: "Bygglov beslut".into(), kind: EventKind::Deadline, case: Some("BL-12".into()) },
            Event { date: d(2026, 6, 15), label: "Yttrande".into(), kind: EventKind::Frist, case: Some("ÖK-3".into()) },
            Event { date: d(2026, 6, 20), label: "Maria semester".into(), kind: EventKind::Absence, case: None },
        ]);
        c
    }
    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(y, m, day).unwrap()
    }

    #[test]
    fn days_in_month_is_correct() {
        assert_eq!(days_in_month(2026, 6), 30);
        assert_eq!(days_in_month(2026, 2), 28);
        assert_eq!(days_in_month(2024, 2), 29); // leap
        assert_eq!(days_in_month(2026, 12), 31);
    }

    #[test]
    fn month_navigation_wraps_year() {
        let mut c = cal();
        c.month = 12;
        c.next_month();
        assert_eq!(c.displayed(), (2027, 1));
        c.prev_month();
        assert_eq!(c.displayed(), (2026, 12));
    }

    #[test]
    fn day_marker_picks_strongest_kind() {
        let c = cal();
        // 15 June has a Deadline + a Frist; Frist outranks → red.
        let (color, count) = c.day_marker(d(2026, 6, 15)).unwrap();
        assert_eq!(count, 2);
        assert_eq!(color, EventKind::Frist.color());
        assert!(c.day_marker(d(2026, 6, 16)).is_none());
    }

    #[test]
    fn state_json_reports_month_and_events() {
        let c = cal();
        let s = c.state_json();
        assert_eq!(s["year"], 2026);
        assert_eq!(s["month"], 6);
        assert_eq!(s["month_name"], "Juni");
        assert_eq!(s["today"], "2026-06-11");
        assert_eq!(s["events"].as_array().unwrap().len(), 3);
    }
}