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