Skip to main content

kasl_server/
heatmap.rs

1//! The month as a shape: `GET /api/v1/team/heatmap`.
2//!
3//! The dashboard answers a week as totals and a moment as a pulse. Neither
4//! shows the pattern a manager actually reads a team by - who works weekends,
5//! whose month is ragged, who stopped filing days on the 12th. This endpoint
6//! is `kasl sum` widened by one axis: a row per person, a cell per local date.
7//!
8//! Three rules, settled in ADR 0015 before the code:
9//!
10//! * **A missing cell is missing data.** Only dates with a workday are
11//!   answered. Filling the month with zeroes would make the employee who never
12//!   installed kasl look like the one who took the month off, and the first
13//!   reading a manager reaches for is the wrong one of the two.
14//! * **An open day has no total.** `worked_seconds` is `null` while the day is
15//!   running, as `/me/days` answers it. A half-lived day is not a short day.
16//! * **The server does not own the scale.** It answers seconds; what counts as
17//!   a full day is a norm, and this installation has none until v0.21. A
18//!   threshold shipped in the API would be an invention that is hard to take
19//!   back.
20
21use axum::{
22    Json,
23    extract::{Query, State},
24    response::IntoResponse,
25};
26use chrono::{Datelike, NaiveDate};
27use serde::{Deserialize, Serialize};
28use uuid::Uuid;
29
30use crate::{
31    admin::{VISIBLE_USERS, require_manager_or_admin},
32    app::AppState,
33    error::ApiError,
34    login::CurrentUser,
35    model::UserRole,
36};
37
38/// The month being asked for, as `YYYY-MM`.
39///
40/// A month rather than a free range: the screen pages by month, the calendar
41/// resolves its own length so February is never off by a day, and anyone who
42/// wants an arbitrary span already has `/me/days`.
43#[derive(Debug, Deserialize)]
44pub struct MonthQuery {
45    pub month: String,
46}
47
48/// One day of one person's month.
49#[derive(Debug, Serialize, PartialEq, Eq)]
50pub struct Cell {
51    /// The employee's own local date, as their agent recorded it (ADR 0003).
52    pub date: NaiveDate,
53    /// Seconds worked: the day's span less what was paused in it. `null` for a
54    /// day still open - it has no total yet, and reporting the hours so far
55    /// would draw a full day as a short one.
56    pub worked_seconds: Option<i64>,
57    /// Whether the day is still running on the agent.
58    pub open: bool,
59}
60
61/// One person's month.
62#[derive(Debug, Serialize)]
63pub struct Row {
64    pub user_id: Uuid,
65    pub display_name: String,
66    pub department: Option<String>,
67    /// Only the dates with a workday, ascending. An empty vector is a real
68    /// answer: this person recorded nothing this month.
69    pub days: Vec<Cell>,
70    /// The longest finished day in this row, in seconds, or `null` when the
71    /// row has no finished day.
72    pub busiest_seconds: Option<i64>,
73    /// The month's total across finished days.
74    pub worked_seconds: i64,
75}
76
77/// The team's month.
78#[derive(Debug, Serialize)]
79pub struct Heatmap {
80    /// The month asked for, echoed as `YYYY-MM`.
81    pub month: String,
82    /// Its first and last dates, so the screen draws the right number of
83    /// columns without repeating the calendar arithmetic.
84    pub from: NaiveDate,
85    pub to: NaiveDate,
86    pub rows: Vec<Row>,
87    /// The busiest single day anywhere in the answer. The shared ceiling a
88    /// screen needs if it wants one scale across the whole grid rather than a
89    /// per-row one, which would make a light week look like a heavy one.
90    pub busiest_seconds: Option<i64>,
91}
92
93/// Answers the team's month.
94pub async fn month(State(state): State<AppState>, user: CurrentUser, Query(query): Query<MonthQuery>) -> Result<impl IntoResponse, ApiError> {
95    require_manager_or_admin(&user)?;
96    let (from, to) = month_bounds(&query.month)?;
97
98    let is_admin = user.role == UserRole::Admin;
99
100    // Grouped in the database rather than folded in Rust: the alternative
101    // fetches every workday of the month for the whole team and reassembles it
102    // here, which is the same rows over the wire for nothing.
103    //
104    // `AssertSqlSafe` because the only interpolation is `VISIBLE_USERS`, a
105    // constant in `admin`; every value from the request is bound below.
106    let cells: Vec<CellRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
107        "SELECT u.id AS user_id, u.display_name, d.name AS department,
108                w.date, w.ended_at IS NULL AS open,
109                CASE WHEN w.ended_at IS NULL THEN NULL
110                     ELSE greatest(extract(epoch FROM (w.ended_at - w.started_at))::bigint - paused.seconds, 0)
111                END AS worked_seconds
112         FROM users u
113         LEFT JOIN departments d ON d.id = u.department_id
114         LEFT JOIN workdays w ON w.user_id = u.id AND w.date BETWEEN $3 AND $4
115         LEFT JOIN LATERAL (
116             -- Stored pauses where they exist; the day's own totals where a
117             -- narrower policy summarized them away (ADR 0011). One or the
118             -- other, never both, so an hour cannot be counted twice.
119             SELECT CASE
120                 WHEN EXISTS (SELECT 1 FROM pauses p WHERE p.workday_id = w.id)
121                 THEN (SELECT coalesce(sum(p.duration_seconds), 0)::bigint FROM pauses p WHERE p.workday_id = w.id)
122                 ELSE coalesce(w.paused_seconds, 0)::bigint
123             END AS seconds
124         ) AS paused ON true
125         WHERE u.active AND {VISIBLE_USERS}
126         ORDER BY u.display_name, u.email, w.date"
127    )))
128    .bind(is_admin)
129    .bind(user.user_id)
130    .bind(from)
131    .bind(to)
132    .fetch_all(&state.pool)
133    .await?;
134
135    let rows = into_rows(cells);
136    let busiest_seconds = rows.iter().filter_map(|row| row.busiest_seconds).max();
137
138    Ok(Json(Heatmap {
139        month: query.month,
140        from,
141        to,
142        rows,
143        busiest_seconds,
144    }))
145}
146
147/// The first and last date of a `YYYY-MM` month.
148///
149/// Separate from the handler so the parsing - and its refusals - can be read
150/// and tested without a database behind them. The end comes from the calendar
151/// rather than from adding 30 days, which is where February goes wrong.
152pub fn month_bounds(month: &str) -> Result<(NaiveDate, NaiveDate), ApiError> {
153    let shape = || ApiError::bad_request(format!("`month` must be YYYY-MM, got `{month}`"));
154
155    // Parsed as a whole date so `2026-08-15` is refused rather than silently
156    // read as August: a caller passing a date means to ask something this
157    // endpoint does not answer, and quietly widening it hides their mistake.
158    let (year, rest) = month.split_once('-').ok_or_else(shape)?;
159    if rest.len() != 2 || year.len() != 4 {
160        return Err(shape());
161    }
162    let first = NaiveDate::parse_from_str(&format!("{month}-01"), "%Y-%m-%d").map_err(|_| shape())?;
163
164    // The first of the next month, stepped back one day: the only arithmetic
165    // that gets December and February right without a table of lengths.
166    let next = if first.month() == 12 {
167        NaiveDate::from_ymd_opt(first.year() + 1, 1, 1)
168    } else {
169        NaiveDate::from_ymd_opt(first.year(), first.month() + 1, 1)
170    };
171    let last = next.and_then(|next| next.pred_opt()).ok_or_else(shape)?;
172
173    Ok((first, last))
174}
175
176/// A row of the query: a person, and one of their days where they have any.
177///
178/// The `LEFT JOIN` means a person with nothing recorded still arrives, with
179/// every day column null. That is the row a manager most needs to see, so it
180/// is carried through rather than filtered out.
181#[derive(Debug, sqlx::FromRow)]
182struct CellRow {
183    user_id: Uuid,
184    display_name: String,
185    department: Option<String>,
186    date: Option<NaiveDate>,
187    open: Option<bool>,
188    worked_seconds: Option<i64>,
189}
190
191/// Folds the flat rows into one entry per person.
192///
193/// Relies on the query's `ORDER BY` grouping each person's rows together, so
194/// this is a single pass rather than a map keyed by id - and the order the
195/// database chose is the order the screen draws.
196fn into_rows(cells: Vec<CellRow>) -> Vec<Row> {
197    let mut rows: Vec<Row> = Vec::new();
198
199    for cell in cells {
200        if rows.last().map(|row| row.user_id) != Some(cell.user_id) {
201            rows.push(Row {
202                user_id: cell.user_id,
203                display_name: cell.display_name,
204                department: cell.department,
205                days: Vec::new(),
206                busiest_seconds: None,
207                worked_seconds: 0,
208            });
209        }
210
211        let row = rows.last_mut().expect("a row was just pushed for this person");
212
213        // No date means the outer join found nothing for this person - the
214        // person is the answer, the day is not.
215        let Some(date) = cell.date else { continue };
216
217        if let Some(seconds) = cell.worked_seconds {
218            row.worked_seconds += seconds;
219            row.busiest_seconds = Some(row.busiest_seconds.map_or(seconds, |busiest: i64| busiest.max(seconds)));
220        }
221
222        row.days.push(Cell {
223            date,
224            worked_seconds: cell.worked_seconds,
225            // A day that arrived without the flag is treated as finished: the
226            // column it comes from is `NOT NULL`, so this cannot happen, and
227            // guessing "open" would put a running day on a month long past.
228            open: cell.open.unwrap_or(false),
229        });
230    }
231
232    rows
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn bounds(month: &str) -> (NaiveDate, NaiveDate) {
240        month_bounds(month).expect("a valid month")
241    }
242
243    fn date(text: &str) -> NaiveDate {
244        text.parse().expect("a test date")
245    }
246
247    #[test]
248    fn a_month_ends_where_the_calendar_says() {
249        // The three lengths, and the one only a leap year gets right.
250        assert_eq!(bounds("2026-01"), (date("2026-01-01"), date("2026-01-31")));
251        assert_eq!(bounds("2026-04"), (date("2026-04-01"), date("2026-04-30")));
252        assert_eq!(bounds("2026-02"), (date("2026-02-01"), date("2026-02-28")));
253        assert_eq!(bounds("2024-02"), (date("2024-02-01"), date("2024-02-29")));
254    }
255
256    #[test]
257    fn december_rolls_into_the_next_year() {
258        // The month after December is not month 13 - arithmetic that forgets
259        // this answers an empty grid every January.
260        assert_eq!(bounds("2026-12"), (date("2026-12-01"), date("2026-12-31")));
261    }
262
263    #[test]
264    fn a_month_that_is_not_a_month_is_refused() {
265        // `2026-08-15` among them: a caller passing a date is asking for
266        // something else, and widening it to the month hides their mistake.
267        for bad in ["2026", "2026-13", "2026-00", "August", "2026-08-15", "26-08", ""] {
268            let error = month_bounds(bad).unwrap_err();
269            assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST, "`{bad}` should be refused");
270            assert!(error.to_string().contains("YYYY-MM"), "the message should say the shape: {error}");
271        }
272    }
273
274    fn cell(user: Uuid, name: &str, date: Option<&str>, worked: Option<i64>, open: bool) -> CellRow {
275        CellRow {
276            user_id: user,
277            display_name: name.to_string(),
278            department: None,
279            date: date.map(|text| text.parse().expect("a test date")),
280            open: date.map(|_| open),
281            worked_seconds: worked,
282        }
283    }
284
285    #[test]
286    fn a_person_with_nothing_recorded_is_still_a_row() {
287        // The empty row is the case the screen exists for: an employee whose
288        // agent never reported must be visible, not quietly dropped.
289        let nobody = Uuid::new_v4();
290        let rows = into_rows(vec![cell(nobody, "Nobody", None, None, false)]);
291
292        assert_eq!(rows.len(), 1);
293        assert!(rows[0].days.is_empty(), "no days, rather than a day with no date");
294        assert_eq!(rows[0].worked_seconds, 0);
295        assert_eq!(rows[0].busiest_seconds, None, "no finished day means no busiest one");
296    }
297
298    #[test]
299    fn an_open_day_is_a_cell_without_a_total() {
300        // It counts as a day recorded and contributes nothing to the totals: a
301        // day still running has no figure, and treating its hours-so-far as
302        // one would draw a full day as a short one.
303        let person = Uuid::new_v4();
304        let rows = into_rows(vec![
305            cell(person, "Ann", Some("2026-09-01"), Some(28_800), false),
306            cell(person, "Ann", Some("2026-09-02"), None, true),
307        ]);
308
309        assert_eq!(rows.len(), 1);
310        assert_eq!(rows[0].days.len(), 2);
311        assert_eq!(rows[0].days[1].worked_seconds, None);
312        assert!(rows[0].days[1].open);
313        assert_eq!(rows[0].worked_seconds, 28_800, "the open day adds nothing");
314        assert_eq!(rows[0].busiest_seconds, Some(28_800));
315    }
316
317    #[test]
318    fn each_persons_days_land_on_their_own_row() {
319        let ann = Uuid::new_v4();
320        let bob = Uuid::new_v4();
321        let rows = into_rows(vec![
322            cell(ann, "Ann", Some("2026-09-01"), Some(3_600), false),
323            cell(ann, "Ann", Some("2026-09-02"), Some(7_200), false),
324            cell(bob, "Bob", Some("2026-09-01"), Some(1_800), false),
325        ]);
326
327        assert_eq!(rows.len(), 2);
328        assert_eq!(rows[0].days.len(), 2);
329        assert_eq!(rows[0].worked_seconds, 10_800);
330        assert_eq!(rows[0].busiest_seconds, Some(7_200), "the longest day, not the last one");
331        assert_eq!(rows[1].days.len(), 1);
332        assert_eq!(rows[1].worked_seconds, 1_800);
333    }
334
335    #[test]
336    fn the_busiest_day_is_the_largest_not_the_latest() {
337        // Guards the fold against `busiest = seconds` overwriting on every
338        // row, which a month of ascending dates would hide.
339        let person = Uuid::new_v4();
340        let rows = into_rows(vec![
341            cell(person, "Ann", Some("2026-09-01"), Some(36_000), false),
342            cell(person, "Ann", Some("2026-09-02"), Some(3_600), false),
343        ]);
344
345        assert_eq!(rows[0].busiest_seconds, Some(36_000));
346    }
347}