1use 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#[derive(Debug, Deserialize)]
44pub struct MonthQuery {
45 pub month: String,
46}
47
48#[derive(Debug, Serialize, PartialEq, Eq)]
50pub struct Cell {
51 pub date: NaiveDate,
53 pub worked_seconds: Option<i64>,
57 pub open: bool,
59}
60
61#[derive(Debug, Serialize)]
63pub struct Row {
64 pub user_id: Uuid,
65 pub display_name: String,
66 pub department: Option<String>,
67 pub days: Vec<Cell>,
70 pub busiest_seconds: Option<i64>,
73 pub worked_seconds: i64,
75}
76
77#[derive(Debug, Serialize)]
79pub struct Heatmap {
80 pub month: String,
82 pub from: NaiveDate,
85 pub to: NaiveDate,
86 pub rows: Vec<Row>,
87 pub busiest_seconds: Option<i64>,
91}
92
93pub 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 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
147pub 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 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 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#[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
191fn 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 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 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 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 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 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 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 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 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}