Skip to main content

max / goingson

7.4 KB · 210 lines History Blame Raw
1 //! A state that frames days, as distinct from an event that occupies them.
2 //!
3 //! Most calendars conflate two things, and this is the second one.
4 //!
5 //! **Some things occupy your day. Others frame it.** An occupancy is something
6 //! you do at a time: it consumes hours, it competes for them, two at once is a
7 //! double-booking. A context is a state you are in over a stretch: it consumes
8 //! no hours, and things happen *inside* it rather than beside it. Being on
9 //! leave does not clash with a meeting; it changes what having a meeting means.
10 //!
11 //! The tell is containment. An occupancy sits *on* the day's axis, a context
12 //! sits *behind* it.
13 //!
14 //! # Authored as a span, read per day
15 //!
16 //! Leave is taken the 3rd to the 17th as one decision, not fifteen. Every view
17 //! then asks whether a day is inside one. Never store the per-day projection
18 //! as if it were the fact.
19
20 use crate::id_types::{ContextId, EventId, UserId};
21 use chrono::{DateTime, NaiveDate, Utc};
22 use serde::{Deserialize, Serialize};
23
24 /// What kind of state a context is.
25 ///
26 /// An open vocabulary rather than a closed enum: the kinds are the user's, and
27 /// a fixed set here would be this crate deciding what can frame a person's
28 /// days. `Vacation` exists as a constant because the migration produces it and
29 /// the weekly review still asks about it by name.
30 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31 #[serde(rename_all = "camelCase")]
32 pub enum ContextKind {
33 /// Time off.
34 Vacation,
35 /// Away, but not off.
36 Trip,
37 /// Unwell.
38 Illness,
39 /// A stretch of work with its own shape.
40 Sprint,
41 /// Something else the user named.
42 Other,
43 }
44
45 impl ContextKind {
46 /// How it is stored, and what the wire carries.
47 #[must_use]
48 pub const fn as_str(self) -> &'static str {
49 match self {
50 Self::Vacation => "Vacation",
51 Self::Trip => "Trip",
52 Self::Illness => "Illness",
53 Self::Sprint => "Sprint",
54 Self::Other => "Other",
55 }
56 }
57
58 /// A stored value read back.
59 ///
60 /// Not `FromStr`: that trait's `Err` would have to be a type nobody
61 /// constructs, because this cannot fail -- an unknown kind is `Other`.
62 ///
63 /// An unknown kind is [`Other`](Self::Other) rather than an error: a row
64 /// written by a newer client is still a real span of days the user is in,
65 /// and refusing to draw it would hide the context rather than the kind.
66 #[must_use]
67 pub fn parse(stored: &str) -> Self {
68 match stored {
69 "Vacation" => Self::Vacation,
70 "Trip" => Self::Trip,
71 "Illness" => Self::Illness,
72 "Sprint" => Self::Sprint,
73 _ => Self::Other,
74 }
75 }
76 }
77
78 /// A stretch of days the user is in some state for.
79 #[derive(Debug, Clone, Serialize, Deserialize)]
80 #[serde(rename_all = "camelCase")]
81 pub struct Context {
82 /// Unique identifier.
83 pub id: ContextId,
84 /// Owner user ID.
85 pub user_id: UserId,
86 /// What the user calls it.
87 pub label: String,
88 /// What kind of state it is.
89 pub kind: ContextKind,
90 /// The first day inside it.
91 pub starts_on: NaiveDate,
92 /// The last day inside it, inclusive.
93 ///
94 /// Inclusive because a context is authored in whole days and read per day:
95 /// "off until the 17th" means the 17th is off. An exclusive end would make
96 /// every reader subtract one, which is the kind of arithmetic that is right
97 /// in four places and wrong in the fifth.
98 pub ends_on: NaiveDate,
99 /// The event this was migrated from, if it was.
100 ///
101 /// Provenance, and the condition under which Max accepted a migration that
102 /// guesses (task `8ee5c4fe`): a conversion that turns a deadline marker
103 /// into a context has to be reversible per record, from the record itself,
104 /// without anything being reconstructed by hand.
105 pub migrated_from_event_id: Option<EventId>,
106 /// When it was recorded.
107 pub created_at: DateTime<Utc>,
108 /// When it was last changed.
109 pub updated_at: DateTime<Utc>,
110 }
111
112 impl Context {
113 /// Whether `day` is inside this context.
114 ///
115 /// Both ends inclusive. The whole read side of the model is this one
116 /// question asked per day, which is why it lives on the record rather than
117 /// being written out at each of the places that asks.
118 #[must_use]
119 pub fn covers(&self, day: NaiveDate) -> bool {
120 self.starts_on <= day && day <= self.ends_on
121 }
122
123 /// How many days it spans, counting both ends.
124 #[must_use]
125 pub fn days(&self) -> i64 {
126 (self.ends_on - self.starts_on).num_days() + 1
127 }
128
129 /// Whether this context and `other` are adjacent or overlapping.
130 ///
131 /// What the `vacation_days` migration needs to join a run crossing two
132 /// weekly reviews into one context rather than two touching ones: nobody
133 /// recording a nine-day holiday meant two holidays (ruling 2 of `8ee5c4fe`).
134 #[must_use]
135 pub fn touches(&self, other: &Self) -> bool {
136 self.starts_on <= other.ends_on.succ_opt().unwrap_or(other.ends_on)
137 && other.starts_on <= self.ends_on.succ_opt().unwrap_or(self.ends_on)
138 }
139 }
140
141 #[cfg(test)]
142 mod tests {
143 use super::*;
144
145 fn ctx(starts: &str, ends: &str) -> Context {
146 Context {
147 id: ContextId::new(),
148 user_id: UserId::new(),
149 label: "Leave".to_owned(),
150 kind: ContextKind::Vacation,
151 starts_on: starts.parse().expect("a date"),
152 ends_on: ends.parse().expect("a date"),
153 migrated_from_event_id: None,
154 created_at: Utc::now(),
155 updated_at: Utc::now(),
156 }
157 }
158
159 fn day(date: &str) -> NaiveDate {
160 date.parse().expect("a date")
161 }
162
163 #[test]
164 fn both_ends_are_inside() {
165 let leave = ctx("2026-08-03", "2026-08-17");
166 assert!(leave.covers(day("2026-08-03")), "the first day is off");
167 assert!(leave.covers(day("2026-08-17")), "and so is the last");
168 assert!(leave.covers(day("2026-08-10")));
169 assert!(!leave.covers(day("2026-08-02")));
170 assert!(!leave.covers(day("2026-08-18")));
171 }
172
173 #[test]
174 fn a_single_day_context_is_one_day_long() {
175 assert_eq!(ctx("2026-08-03", "2026-08-03").days(), 1);
176 assert_eq!(ctx("2026-08-03", "2026-08-17").days(), 15);
177 }
178
179 #[test]
180 fn touching_runs_are_joinable_and_a_gap_is_not() {
181 // Thu-Sun then Mon-Tue: one holiday recorded in two weekly reviews.
182 let first = ctx("2026-08-06", "2026-08-09");
183 let second = ctx("2026-08-10", "2026-08-11");
184 assert!(first.touches(&second), "adjacent days are one run");
185 assert!(
186 second.touches(&first),
187 "and it does not depend on the order"
188 );
189
190 let apart = ctx("2026-08-12", "2026-08-13");
191 assert!(!first.touches(&apart), "a clear day between is two runs");
192 }
193
194 #[test]
195 fn an_unknown_kind_reads_as_other_rather_than_failing() {
196 assert_eq!(ContextKind::parse("Sabbatical"), ContextKind::Other);
197 assert_eq!(ContextKind::parse("Vacation"), ContextKind::Vacation);
198 // And every kind round-trips through the form it is stored in.
199 for kind in [
200 ContextKind::Vacation,
201 ContextKind::Trip,
202 ContextKind::Illness,
203 ContextKind::Sprint,
204 ContextKind::Other,
205 ] {
206 assert_eq!(ContextKind::parse(kind.as_str()), kind);
207 }
208 }
209 }
210