//! A state that frames days, as distinct from an event that occupies them. //! //! Most calendars conflate two things, and this is the second one. //! //! **Some things occupy your day. Others frame it.** An occupancy is something //! you do at a time: it consumes hours, it competes for them, two at once is a //! double-booking. A context is a state you are in over a stretch: it consumes //! no hours, and things happen *inside* it rather than beside it. Being on //! leave does not clash with a meeting; it changes what having a meeting means. //! //! The tell is containment. An occupancy sits *on* the day's axis, a context //! sits *behind* it. //! //! # Authored as a span, read per day //! //! Leave is taken the 3rd to the 17th as one decision, not fifteen. Every view //! then asks whether a day is inside one. Never store the per-day projection //! as if it were the fact. use crate::id_types::{ContextId, EventId, UserId}; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; /// What kind of state a context is. /// /// An open vocabulary rather than a closed enum: the kinds are the user's, and /// a fixed set here would be this crate deciding what can frame a person's /// days. `Vacation` exists as a constant because the migration produces it and /// the weekly review still asks about it by name. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ContextKind { /// Time off. Vacation, /// Away, but not off. Trip, /// Unwell. Illness, /// A stretch of work with its own shape. Sprint, /// Something else the user named. Other, } impl ContextKind { /// How it is stored, and what the wire carries. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Vacation => "Vacation", Self::Trip => "Trip", Self::Illness => "Illness", Self::Sprint => "Sprint", Self::Other => "Other", } } /// A stored value read back. /// /// Not `FromStr`: that trait's `Err` would have to be a type nobody /// constructs, because this cannot fail -- an unknown kind is `Other`. /// /// An unknown kind is [`Other`](Self::Other) rather than an error: a row /// written by a newer client is still a real span of days the user is in, /// and refusing to draw it would hide the context rather than the kind. #[must_use] pub fn parse(stored: &str) -> Self { match stored { "Vacation" => Self::Vacation, "Trip" => Self::Trip, "Illness" => Self::Illness, "Sprint" => Self::Sprint, _ => Self::Other, } } } /// A stretch of days the user is in some state for. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Context { /// Unique identifier. pub id: ContextId, /// Owner user ID. pub user_id: UserId, /// What the user calls it. pub label: String, /// What kind of state it is. pub kind: ContextKind, /// The first day inside it. pub starts_on: NaiveDate, /// The last day inside it, inclusive. /// /// Inclusive because a context is authored in whole days and read per day: /// "off until the 17th" means the 17th is off. An exclusive end would make /// every reader subtract one, which is the kind of arithmetic that is right /// in four places and wrong in the fifth. pub ends_on: NaiveDate, /// The event this was migrated from, if it was. /// /// Provenance, and the condition under which Max accepted a migration that /// guesses (task `8ee5c4fe`): a conversion that turns a deadline marker /// into a context has to be reversible per record, from the record itself, /// without anything being reconstructed by hand. pub migrated_from_event_id: Option, /// When it was recorded. pub created_at: DateTime, /// When it was last changed. pub updated_at: DateTime, } impl Context { /// Whether `day` is inside this context. /// /// Both ends inclusive. The whole read side of the model is this one /// question asked per day, which is why it lives on the record rather than /// being written out at each of the places that asks. #[must_use] pub fn covers(&self, day: NaiveDate) -> bool { self.starts_on <= day && day <= self.ends_on } /// How many days it spans, counting both ends. #[must_use] pub fn days(&self) -> i64 { (self.ends_on - self.starts_on).num_days() + 1 } /// Whether this context and `other` are adjacent or overlapping. /// /// What the `vacation_days` migration needs to join a run crossing two /// weekly reviews into one context rather than two touching ones: nobody /// recording a nine-day holiday meant two holidays (ruling 2 of `8ee5c4fe`). #[must_use] pub fn touches(&self, other: &Self) -> bool { self.starts_on <= other.ends_on.succ_opt().unwrap_or(other.ends_on) && other.starts_on <= self.ends_on.succ_opt().unwrap_or(self.ends_on) } } #[cfg(test)] mod tests { use super::*; fn ctx(starts: &str, ends: &str) -> Context { Context { id: ContextId::new(), user_id: UserId::new(), label: "Leave".to_owned(), kind: ContextKind::Vacation, starts_on: starts.parse().expect("a date"), ends_on: ends.parse().expect("a date"), migrated_from_event_id: None, created_at: Utc::now(), updated_at: Utc::now(), } } fn day(date: &str) -> NaiveDate { date.parse().expect("a date") } #[test] fn both_ends_are_inside() { let leave = ctx("2026-08-03", "2026-08-17"); assert!(leave.covers(day("2026-08-03")), "the first day is off"); assert!(leave.covers(day("2026-08-17")), "and so is the last"); assert!(leave.covers(day("2026-08-10"))); assert!(!leave.covers(day("2026-08-02"))); assert!(!leave.covers(day("2026-08-18"))); } #[test] fn a_single_day_context_is_one_day_long() { assert_eq!(ctx("2026-08-03", "2026-08-03").days(), 1); assert_eq!(ctx("2026-08-03", "2026-08-17").days(), 15); } #[test] fn touching_runs_are_joinable_and_a_gap_is_not() { // Thu-Sun then Mon-Tue: one holiday recorded in two weekly reviews. let first = ctx("2026-08-06", "2026-08-09"); let second = ctx("2026-08-10", "2026-08-11"); assert!(first.touches(&second), "adjacent days are one run"); assert!( second.touches(&first), "and it does not depend on the order" ); let apart = ctx("2026-08-12", "2026-08-13"); assert!(!first.touches(&apart), "a clear day between is two runs"); } #[test] fn an_unknown_kind_reads_as_other_rather_than_failing() { assert_eq!(ContextKind::parse("Sabbatical"), ContextKind::Other); assert_eq!(ContextKind::parse("Vacation"), ContextKind::Vacation); // And every kind round-trips through the form it is stored in. for kind in [ ContextKind::Vacation, ContextKind::Trip, ContextKind::Illness, ContextKind::Sprint, ContextKind::Other, ] { assert_eq!(ContextKind::parse(kind.as_str()), kind); } } }