Skip to main content

kasl_server/
calendar.rs

1//! The production calendar and the norm: how much a date was meant to be
2//! worked, and by whom.
3//!
4//! This is the first standard the server holds. Everything before it compared
5//! a person with their own history and said so (ADR 0016), because there was
6//! nothing else to compare them with; the heatmap left the notion of a full
7//! day to the client for the same reason (ADR 0015).
8//!
9//! Three facts make the norm, and each is stored exactly once (ADR 0017):
10//!
11//! * **The calendar** holds only the dates that differ from the weekday they
12//!   fall on - a holiday, a shortened eve, a Saturday moved into the working
13//!   week. A list of holidays could only subtract, and a production calendar
14//!   also adds.
15//! * **The installation's full day** (`settings.standard_hours`) says what a
16//!   whole day of work is here.
17//! * **A person's share of it** (`users.work_rate`) says whose day is half
18//!   that, and stays half when the other two change.
19//!
20//! The norm itself is derived from them on every read. Storing it would put a
21//! second copy of a computed fact next to the rows that produce it, and the
22//! copy would survive a correction to the calendar that the derivation simply
23//! absorbs.
24
25use axum::{
26    Json,
27    extract::{Path, Query, State},
28    http::StatusCode,
29    response::IntoResponse,
30};
31use chrono::{Datelike, NaiveDate, Weekday};
32use rust_decimal::{Decimal, prelude::ToPrimitive};
33use serde::{Deserialize, Serialize};
34use sqlx::PgPool;
35use uuid::Uuid;
36
37use crate::{app::AppState, audit, error::ApiError, login::CurrentUser, me::Range};
38
39/// Seconds in an hour, as a decimal, so an hour figure becomes seconds without
40/// going through a float on the way.
41const SECONDS_PER_HOUR: i64 = 3600;
42
43/// How much a short day is shortened by, in hours.
44///
45/// One, everywhere this rule exists. Not a setting: an installation that needs
46/// a different figure needs a different rule, and a knob here would ask the
47/// operator to invent a calendar convention rather than record one.
48const SHORT_DAY_RELIEF_HOURS: i64 = 1;
49
50/// What makes a date unlike the weekday it falls on.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
52#[sqlx(type_name = "calendar_day_kind", rename_all = "snake_case")]
53#[serde(rename_all = "snake_case")]
54pub enum CalendarDayKind {
55    /// A working weekday that is not worked.
56    Holiday,
57    /// The eve of a holiday: one hour shorter.
58    ShortDay,
59    /// A weekend day moved into the working week, usually because a holiday
60    /// was transferred off it.
61    WorkingWeekend,
62}
63
64/// What kind of day an employee had, as their agent reported it.
65///
66/// Optional on the wire and defaulting to `Work`: an agent that predates the
67/// field says `work` by saying nothing (ADR 0004).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, sqlx::Type)]
69#[sqlx(type_name = "workday_kind", rename_all = "snake_case")]
70#[serde(rename_all = "snake_case")]
71pub enum WorkdayKind {
72    #[default]
73    Work,
74    Vacation,
75    Sick,
76    DayOff,
77}
78
79impl WorkdayKind {
80    /// Whether a day of this kind owes the norm.
81    ///
82    /// Leave and illness owe nothing. Without this a fortnight of holiday
83    /// reads as eighty hours missing, which is the most alarming possible way
84    /// to be wrong about somebody on a beach.
85    pub fn owes_the_norm(self) -> bool {
86        matches!(self, Self::Work)
87    }
88}
89
90/// One dated exception, as stored and as the API answers it.
91#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
92pub struct CalendarDay {
93    pub date: NaiveDate,
94    pub kind: CalendarDayKind,
95    /// What the day is called, for the screen that lists a year. The date and
96    /// the kind are the calendar; the name is for people.
97    pub note: Option<String>,
98}
99
100/// The calendar over a span of dates, as a lookup.
101///
102/// Loaded once per request rather than queried per day: a month view asks
103/// about thirty dates, and thirty round trips for a dozen rows is the shape of
104/// query that looks fine on a laptop and shows up on a dashboard.
105#[derive(Debug, Clone, Default)]
106pub struct Calendar {
107    /// Sorted by date, so a lookup binary-searches rather than hashing.
108    days: Vec<CalendarDay>,
109}
110
111impl Calendar {
112    /// Loads the exceptions between two dates, both ends inclusive.
113    pub async fn load(pool: &PgPool, from: NaiveDate, to: NaiveDate) -> Result<Self, ApiError> {
114        let days: Vec<CalendarDay> = sqlx::query_as("SELECT date, kind, note FROM calendar_days WHERE date BETWEEN $1 AND $2 ORDER BY date")
115            .bind(from)
116            .bind(to)
117            .fetch_all(pool)
118            .await?;
119        Ok(Self { days })
120    }
121
122    /// The calendar with nothing in it: every weekday a full day, every
123    /// weekend off. What an installation that has entered no calendar gets.
124    pub fn empty() -> Self {
125        Self::default()
126    }
127
128    /// Builds one from rows already in hand - the demo seed, and the tests.
129    pub fn from_days(mut days: Vec<CalendarDay>) -> Self {
130        days.sort_by_key(|day| day.date);
131        Self { days }
132    }
133
134    /// The exceptions themselves, for a caller that lists them.
135    pub fn days(&self) -> &[CalendarDay] {
136        &self.days
137    }
138
139    fn kind_of(&self, date: NaiveDate) -> Option<CalendarDayKind> {
140        self.days.binary_search_by_key(&date, |day| day.date).ok().map(|at| self.days[at].kind)
141    }
142
143    /// The hours a date is worth at full rate.
144    ///
145    /// The whole rule in one place (ADR 0017): a weekend is nothing unless the
146    /// calendar moved it into the week, a holiday is nothing, an eve is an
147    /// hour less, and everything else is a full day.
148    pub fn hours_on(&self, date: NaiveDate, standard_hours: Decimal) -> Decimal {
149        match self.kind_of(date) {
150            Some(CalendarDayKind::Holiday) => Decimal::ZERO,
151            Some(CalendarDayKind::ShortDay) => (standard_hours - Decimal::from(SHORT_DAY_RELIEF_HOURS)).max(Decimal::ZERO),
152            Some(CalendarDayKind::WorkingWeekend) => standard_hours,
153            None if is_weekend(date) => Decimal::ZERO,
154            None => standard_hours,
155        }
156    }
157
158    /// The seconds one person owes on a date.
159    ///
160    /// The rate multiplies last, and the rounding happens once at the end: a
161    /// half-rate short day is `(8 - 1) x 0.5`, not a rounded seven halved.
162    pub fn norm_seconds(&self, date: NaiveDate, standard_hours: Decimal, work_rate: Decimal) -> i64 {
163        to_seconds(self.hours_on(date, standard_hours) * work_rate)
164    }
165
166    /// The seconds one person owes across a range, both ends inclusive.
167    ///
168    /// Days the employee was away are the caller's business: this counts what
169    /// the calendar asks for, and [`Norm::for_range`] subtracts the leave.
170    pub fn norm_seconds_over(&self, from: NaiveDate, to: NaiveDate, standard_hours: Decimal, work_rate: Decimal) -> i64 {
171        let mut total = 0;
172        let mut date = from;
173        while date <= to {
174            total += self.norm_seconds(date, standard_hours, work_rate);
175            let Some(next) = date.succ_opt() else { break };
176            date = next;
177        }
178        total
179    }
180}
181
182/// Whether a date falls on a weekend, before the calendar has its say.
183fn is_weekend(date: NaiveDate) -> bool {
184    matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
185}
186
187/// Hours to whole seconds.
188///
189/// Rounded rather than truncated: a rate of a third of a day would otherwise
190/// lose a second per day and a minute per year, always in the same direction.
191fn to_seconds(hours: Decimal) -> i64 {
192    (hours * Decimal::from(SECONDS_PER_HOUR)).round().to_i64().unwrap_or(0)
193}
194
195/// The installation's full day and one person's share of it.
196///
197/// Carried together because neither means anything alone: the hours without
198/// the rate report a half-time employee as half a person, and the rate without
199/// the hours is a fraction of nothing.
200#[derive(Debug, Clone, Copy)]
201pub struct Norm {
202    pub standard_hours: Decimal,
203    pub work_rate: Decimal,
204}
205
206impl Norm {
207    /// Reads the installation's full day and one person's rate.
208    pub async fn load(pool: &PgPool, user_id: Uuid) -> Result<Self, ApiError> {
209        let standard_hours = Self::standard_hours(pool).await?;
210        let work_rate: Decimal = sqlx::query_scalar("SELECT work_rate FROM users WHERE id = $1")
211            .bind(user_id)
212            .fetch_optional(pool)
213            .await?
214            .unwrap_or(Decimal::ONE);
215        Ok(Self { standard_hours, work_rate })
216    }
217
218    /// The installation's full day alone, for a caller that holds the rates
219    /// itself - the team table reads one rate per row.
220    pub async fn standard_hours(pool: &PgPool) -> Result<Decimal, ApiError> {
221        Ok(sqlx::query_scalar("SELECT standard_hours FROM settings WHERE singleton")
222            .fetch_one(pool)
223            .await?)
224    }
225
226    /// What one person owes over a range, with the days they were away taken
227    /// out of it.
228    ///
229    /// `away` are the dates whose stored day is leave or illness. They are
230    /// removed from the norm rather than counted as worked: a week of holiday
231    /// is a week that owes nothing, not a week worked in full.
232    pub fn for_range(&self, calendar: &Calendar, from: NaiveDate, to: NaiveDate, away: &[NaiveDate]) -> i64 {
233        let full = calendar.norm_seconds_over(from, to, self.standard_hours, self.work_rate);
234        let excused: i64 = away
235            .iter()
236            .filter(|date| **date >= from && **date <= to)
237            .map(|date| calendar.norm_seconds(*date, self.standard_hours, self.work_rate))
238            .sum();
239        (full - excused).max(0)
240    }
241}
242
243/// What the norm endpoints answer alongside hours worked.
244///
245/// A pair, never a percentage: the screen divides. A server that answered
246/// "80%" would have decided that eight hours out of ten is the same fact as
247/// four out of five, and thrown away the two numbers a person reads.
248#[derive(Debug, Clone, Copy, Serialize)]
249pub struct Progress {
250    /// Seconds the calendar and the person's rate ask for, over the range,
251    /// less the days they were on leave.
252    pub norm_seconds: i64,
253    /// The installation's full day, in hours, so a screen can say what a day
254    /// is worth without a second request.
255    pub standard_hours: Decimal,
256    /// This person's share of it.
257    pub work_rate: Decimal,
258}
259
260// The administrative API ------------------------------------------------------
261
262/// The year being asked for. A year at a time is how a calendar is published
263/// and how an administrator checks one.
264#[derive(Debug, Deserialize)]
265pub struct YearQuery {
266    pub year: i32,
267}
268
269/// The calendar of a year, with the installation's full day beside it.
270#[derive(Debug, Serialize)]
271pub struct CalendarYear {
272    pub year: i32,
273    pub days: Vec<CalendarDay>,
274    /// The full day this installation works, so a screen showing the calendar
275    /// does not need a second request to say what a day is worth.
276    pub standard_hours: Decimal,
277}
278
279/// A day being entered or corrected.
280#[derive(Debug, Deserialize)]
281pub struct CalendarDayInput {
282    pub date: NaiveDate,
283    pub kind: CalendarDayKind,
284    #[serde(default)]
285    pub note: Option<String>,
286}
287
288/// A year's worth of exceptions, replacing whatever that year held.
289///
290/// Whole years rather than day by day, because that is how the source document
291/// arrives: a decree publishes a year, and an administrator who has to add
292/// eleven days one at a time will get one of them wrong.
293#[derive(Debug, Deserialize)]
294pub struct CalendarYearInput {
295    pub days: Vec<CalendarDayInput>,
296}
297
298/// The installation's full day being set.
299#[derive(Debug, Deserialize)]
300pub struct StandardHoursInput {
301    pub standard_hours: Decimal,
302}
303
304/// A person's share of a full day being set.
305#[derive(Debug, Deserialize)]
306pub struct WorkRateInput {
307    pub work_rate: Decimal,
308}
309
310/// Answers a year of the calendar.
311///
312/// Readable by anyone signed in: which days of the year are worked is not a
313/// secret from the people working them, and the employee's own screen shows
314/// their norm beside their hours.
315pub async fn year(State(state): State<AppState>, _user: CurrentUser, Query(query): Query<YearQuery>) -> Result<impl IntoResponse, ApiError> {
316    let (from, to) = year_bounds(query.year)?;
317    let calendar = Calendar::load(&state.pool, from, to).await?;
318
319    Ok(Json(CalendarYear {
320        year: query.year,
321        days: calendar.days,
322        standard_hours: Norm::standard_hours(&state.pool).await?,
323    }))
324}
325
326/// Replaces a year of the calendar. Administrators only, and recorded.
327///
328/// A replacement rather than a merge: a corrected calendar is the document
329/// that is right, and merging would leave last week's wrong rows in place with
330/// nothing on the screen to say they are still there.
331pub async fn put_year(
332    State(state): State<AppState>,
333    user: CurrentUser,
334    Query(query): Query<YearQuery>,
335    Json(input): Json<CalendarYearInput>,
336) -> Result<impl IntoResponse, ApiError> {
337    user.require_admin()?;
338    let (from, to) = year_bounds(query.year)?;
339
340    for (index, day) in input.days.iter().enumerate() {
341        if day.date < from || day.date > to {
342            return Err(ApiError::bad_request(format!("days[{index}]: {} is not in {}", day.date, query.year)));
343        }
344    }
345
346    // All of it or none: a half-written year is a calendar nobody can check
347    // against the document it came from.
348    let mut tx = state.pool.begin().await?;
349
350    sqlx::query("DELETE FROM calendar_days WHERE date BETWEEN $1 AND $2")
351        .bind(from)
352        .bind(to)
353        .execute(&mut *tx)
354        .await?;
355
356    for day in &input.days {
357        sqlx::query("INSERT INTO calendar_days (date, kind, note) VALUES ($1, $2, $3)")
358            .bind(day.date)
359            .bind(day.kind)
360            .bind(day.note.as_deref().map(str::trim).filter(|note| !note.is_empty()))
361            .execute(&mut *tx)
362            .await
363            // A duplicate date is the administrator's typo, not a server
364            // fault: the primary key catches it, and a 500 would tell them
365            // nothing about which day they entered twice.
366            .map_err(|error| match &error {
367                sqlx::Error::Database(db) if db.is_unique_violation() => ApiError::bad_request(format!("{} appears twice", day.date)),
368                _ => ApiError::from(error),
369            })?;
370    }
371
372    tx.commit().await?;
373
374    tracing::info!(year = query.year, days = input.days.len(), by = %user.user_id, "replaced a year of the calendar");
375    audit::Entry::new(audit::action::CALENDAR_YEAR_REPLACED)
376        .by(user.user_id)
377        .by_email(&user.email)
378        .with(serde_json::json!({ "year": query.year, "days": input.days.len() }))
379        .record(&state.pool)
380        .await;
381
382    let calendar = Calendar::load(&state.pool, from, to).await?;
383    Ok((
384        StatusCode::OK,
385        Json(CalendarYear {
386            year: query.year,
387            days: calendar.days,
388            standard_hours: Norm::standard_hours(&state.pool).await?,
389        }),
390    ))
391}
392
393/// Sets the installation's full day. Administrators only, and recorded.
394pub async fn put_standard_hours(
395    State(state): State<AppState>,
396    user: CurrentUser,
397    Json(input): Json<StandardHoursInput>,
398) -> Result<impl IntoResponse, ApiError> {
399    user.require_admin()?;
400
401    if input.standard_hours <= Decimal::ZERO || input.standard_hours > Decimal::from(24) {
402        return Err(ApiError::bad_request("a full day is more than zero hours and at most 24"));
403    }
404
405    let previous: Decimal = sqlx::query_scalar("SELECT standard_hours FROM settings WHERE singleton")
406        .fetch_one(&state.pool)
407        .await?;
408
409    sqlx::query("UPDATE settings SET standard_hours = $1 WHERE singleton")
410        .bind(input.standard_hours)
411        .execute(&state.pool)
412        .await?;
413
414    tracing::info!(from = %previous, to = %input.standard_hours, by = %user.user_id, "changed the installation's full day");
415    // The figure every norm on every screen is computed from: a change to it
416    // moves everybody's numbers at once, and has to leave a trace saying who.
417    audit::Entry::new(audit::action::STANDARD_HOURS_CHANGED)
418        .by(user.user_id)
419        .by_email(&user.email)
420        .with(serde_json::json!({ "from": previous, "to": input.standard_hours }))
421        .record(&state.pool)
422        .await;
423
424    Ok((StatusCode::OK, Json(serde_json::json!({ "standard_hours": input.standard_hours }))))
425}
426
427/// Sets one person's share of a full day. Administrators only, and recorded.
428///
429/// Its own route rather than a field on the user patch: this is the one
430/// attribute of an account that changes what every screen says about the
431/// person, and an audit entry naming it is easier to find than one that says
432/// "user updated".
433pub async fn put_work_rate(
434    State(state): State<AppState>,
435    user: CurrentUser,
436    Path(target): Path<Uuid>,
437    Json(input): Json<WorkRateInput>,
438) -> Result<impl IntoResponse, ApiError> {
439    user.require_admin()?;
440
441    if input.work_rate < Decimal::ZERO || input.work_rate > Decimal::from(2) {
442        return Err(ApiError::bad_request("a share of a full day is between 0 and 2"));
443    }
444
445    // The previous value comes back from the same statement that replaces it:
446    // reading it first would let two administrators record each other's
447    // starting point.
448    let previous: Option<Decimal> = sqlx::query_scalar(
449        "UPDATE users SET work_rate = $1 FROM (SELECT work_rate FROM users WHERE id = $2) AS before
450         WHERE users.id = $2 RETURNING before.work_rate",
451    )
452    .bind(input.work_rate)
453    .bind(target)
454    .fetch_optional(&state.pool)
455    .await?;
456
457    let Some(previous) = previous else {
458        return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
459    };
460
461    tracing::info!(user = %target, from = %previous, to = %input.work_rate, by = %user.user_id, "changed a work rate");
462    audit::Entry::new(audit::action::WORK_RATE_CHANGED)
463        .by(user.user_id)
464        .by_email(&user.email)
465        .on(target)
466        .with(serde_json::json!({ "from": previous, "to": input.work_rate }))
467        .record(&state.pool)
468        .await;
469
470    Ok((StatusCode::OK, Json(serde_json::json!({ "work_rate": input.work_rate }))))
471}
472
473/// The first and last date of a year, refusing one the calendar cannot hold.
474fn year_bounds(year: i32) -> Result<(NaiveDate, NaiveDate), ApiError> {
475    let from = NaiveDate::from_ymd_opt(year, 1, 1).ok_or_else(|| ApiError::bad_request(format!("{year} is not a year")))?;
476    let to = NaiveDate::from_ymd_opt(year, 12, 31).ok_or_else(|| ApiError::bad_request(format!("{year} is not a year")))?;
477    Ok((from, to))
478}
479
480/// The dates in a range a person was away, so a norm can excuse them.
481pub async fn away_dates(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Vec<NaiveDate>, ApiError> {
482    let dates: Vec<NaiveDate> = sqlx::query_scalar("SELECT date FROM workdays WHERE user_id = $1 AND date BETWEEN $2 AND $3 AND kind <> 'work' ORDER BY date")
483        .bind(user_id)
484        .bind(range.from)
485        .bind(range.to)
486        .fetch_all(pool)
487        .await?;
488    Ok(dates)
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn date(text: &str) -> NaiveDate {
496        text.parse().expect("a test date")
497    }
498
499    fn day(text: &str, kind: CalendarDayKind) -> CalendarDay {
500        CalendarDay {
501            date: date(text),
502            kind,
503            note: None,
504        }
505    }
506
507    fn eight() -> Decimal {
508        Decimal::from(8)
509    }
510
511    #[test]
512    fn an_empty_calendar_works_the_weekdays() {
513        let calendar = Calendar::empty();
514        // 2026-09-14 is a Monday, 2026-09-19 a Saturday.
515        assert_eq!(calendar.hours_on(date("2026-09-14"), eight()), eight());
516        assert_eq!(calendar.hours_on(date("2026-09-19"), eight()), Decimal::ZERO);
517        assert_eq!(calendar.hours_on(date("2026-09-20"), eight()), Decimal::ZERO);
518    }
519
520    #[test]
521    fn a_holiday_is_worth_nothing_and_an_eve_an_hour_less() {
522        let calendar = Calendar::from_days(vec![day("2026-01-01", CalendarDayKind::Holiday), day("2025-12-31", CalendarDayKind::ShortDay)]);
523        assert_eq!(calendar.hours_on(date("2026-01-01"), eight()), Decimal::ZERO);
524        assert_eq!(calendar.hours_on(date("2025-12-31"), eight()), Decimal::from(7));
525    }
526
527    #[test]
528    fn a_working_weekend_is_a_full_day() {
529        // The case a list of holidays cannot express: a calendar also adds
530        // days, and without this the transferred Saturday owes nothing while
531        // the whole team is at work (ADR 0017).
532        let saturday = date("2026-11-07");
533        assert_eq!(saturday.weekday(), Weekday::Sat);
534
535        assert_eq!(Calendar::empty().hours_on(saturday, eight()), Decimal::ZERO);
536        let calendar = Calendar::from_days(vec![day("2026-11-07", CalendarDayKind::WorkingWeekend)]);
537        assert_eq!(calendar.hours_on(saturday, eight()), eight());
538    }
539
540    #[test]
541    fn the_rate_multiplies_the_shortened_day_not_the_other_way() {
542        // Half of a seven-hour eve is three and a half hours. Applying the
543        // relief after the rate would give three, and the difference is half
544        // an hour of somebody's month every time a short day comes round.
545        let calendar = Calendar::from_days(vec![day("2026-12-31", CalendarDayKind::ShortDay)]);
546        let half = Decimal::new(5, 1);
547        assert_eq!(calendar.norm_seconds(date("2026-12-31"), eight(), half), 3 * 3600 + 1800);
548    }
549
550    #[test]
551    fn a_week_sums_its_days() {
552        // Monday to Sunday: five full days, the weekend nothing.
553        let total = Calendar::empty().norm_seconds_over(date("2026-09-14"), date("2026-09-20"), eight(), Decimal::ONE);
554        assert_eq!(total, 5 * 8 * 3600);
555    }
556
557    #[test]
558    fn a_holiday_takes_its_day_out_of_the_week() {
559        let calendar = Calendar::from_days(vec![day("2026-09-16", CalendarDayKind::Holiday)]);
560        let total = calendar.norm_seconds_over(date("2026-09-14"), date("2026-09-20"), eight(), Decimal::ONE);
561        assert_eq!(total, 4 * 8 * 3600, "the week owes four days once a Wednesday is a holiday");
562    }
563
564    #[test]
565    fn leave_is_excused_rather_than_counted_as_worked() {
566        // The defect this guards: a fortnight of holiday reported as eighty
567        // hours missing.
568        let norm = Norm {
569            standard_hours: eight(),
570            work_rate: Decimal::ONE,
571        };
572        let calendar = Calendar::empty();
573        let (monday, sunday) = (date("2026-09-14"), date("2026-09-20"));
574
575        assert_eq!(norm.for_range(&calendar, monday, sunday, &[]), 5 * 8 * 3600);
576        assert_eq!(
577            norm.for_range(&calendar, monday, sunday, &[date("2026-09-15"), date("2026-09-16")]),
578            3 * 8 * 3600,
579            "two days of leave are not owed"
580        );
581    }
582
583    #[test]
584    fn a_weekend_of_leave_excuses_nothing_it_did_not_owe() {
585        // Taking Saturday off cannot reduce a norm that never asked for it -
586        // otherwise a day of leave on a weekend would quietly credit the week
587        // with eight hours nobody was due to work.
588        let norm = Norm {
589            standard_hours: eight(),
590            work_rate: Decimal::ONE,
591        };
592        let total = norm.for_range(&Calendar::empty(), date("2026-09-14"), date("2026-09-20"), &[date("2026-09-19")]);
593        assert_eq!(total, 5 * 8 * 3600);
594    }
595
596    #[test]
597    fn a_day_the_employee_was_away_owes_nothing() {
598        assert!(WorkdayKind::Work.owes_the_norm());
599        for away in [WorkdayKind::Vacation, WorkdayKind::Sick, WorkdayKind::DayOff] {
600            assert!(!away.owes_the_norm(), "{away:?} owes no hours");
601        }
602    }
603
604    #[test]
605    fn the_wire_names_are_snake_case() {
606        // Part of the contract with kasl and the web UI: a rename here is a
607        // breaking change, so it fails a test rather than a client.
608        assert_eq!(serde_json::to_string(&WorkdayKind::DayOff).unwrap(), "\"day_off\"");
609        assert_eq!(serde_json::to_string(&CalendarDayKind::WorkingWeekend).unwrap(), "\"working_weekend\"");
610        assert_eq!(serde_json::to_string(&CalendarDayKind::ShortDay).unwrap(), "\"short_day\"");
611    }
612
613    #[test]
614    fn an_agent_that_says_nothing_worked() {
615        // The compatibility hinge, as with `tasks_are_complete` before it: a
616        // kasl too old to know the field must not have its silence read as
617        // "this person was on holiday".
618        assert_eq!(WorkdayKind::default(), WorkdayKind::Work);
619        assert_eq!(serde_json::from_str::<WorkdayKind>("\"work\"").unwrap(), WorkdayKind::Work);
620    }
621
622    #[test]
623    fn a_year_outside_the_calendar_is_refused() {
624        assert!(year_bounds(2026).is_ok());
625        let error = year_bounds(i32::MAX).unwrap_err();
626        assert_eq!(error.status(), StatusCode::BAD_REQUEST);
627    }
628}