use axum::{
Json,
extract::{Query, State},
response::IntoResponse,
};
use chrono::{Datelike, NaiveDate};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
admin::{VISIBLE_USERS, require_manager_or_admin},
app::AppState,
error::ApiError,
login::CurrentUser,
model::UserRole,
};
#[derive(Debug, Deserialize)]
pub struct MonthQuery {
pub month: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct Cell {
pub date: NaiveDate,
pub worked_seconds: Option<i64>,
pub open: bool,
}
#[derive(Debug, Serialize)]
pub struct Row {
pub user_id: Uuid,
pub display_name: String,
pub department: Option<String>,
pub days: Vec<Cell>,
pub busiest_seconds: Option<i64>,
pub worked_seconds: i64,
}
#[derive(Debug, Serialize)]
pub struct Heatmap {
pub month: String,
pub from: NaiveDate,
pub to: NaiveDate,
pub rows: Vec<Row>,
pub busiest_seconds: Option<i64>,
}
pub async fn month(State(state): State<AppState>, user: CurrentUser, Query(query): Query<MonthQuery>) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
let (from, to) = month_bounds(&query.month)?;
let is_admin = user.role == UserRole::Admin;
let cells: Vec<CellRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT u.id AS user_id, u.display_name, d.name AS department,
w.date, w.ended_at IS NULL AS open,
CASE WHEN w.ended_at IS NULL THEN NULL
ELSE greatest(extract(epoch FROM (w.ended_at - w.started_at))::bigint - paused.seconds, 0)
END AS worked_seconds
FROM users u
LEFT JOIN departments d ON d.id = u.department_id
LEFT JOIN workdays w ON w.user_id = u.id AND w.date BETWEEN $3 AND $4
LEFT JOIN LATERAL (
-- Stored pauses where they exist; the day's own totals where a
-- narrower policy summarized them away (ADR 0011). One or the
-- other, never both, so an hour cannot be counted twice.
SELECT CASE
WHEN EXISTS (SELECT 1 FROM pauses p WHERE p.workday_id = w.id)
THEN (SELECT coalesce(sum(p.duration_seconds), 0)::bigint FROM pauses p WHERE p.workday_id = w.id)
ELSE coalesce(w.paused_seconds, 0)::bigint
END AS seconds
) AS paused ON true
WHERE u.active AND {VISIBLE_USERS}
ORDER BY u.display_name, u.email, w.date"
)))
.bind(is_admin)
.bind(user.user_id)
.bind(from)
.bind(to)
.fetch_all(&state.pool)
.await?;
let rows = into_rows(cells);
let busiest_seconds = rows.iter().filter_map(|row| row.busiest_seconds).max();
Ok(Json(Heatmap {
month: query.month,
from,
to,
rows,
busiest_seconds,
}))
}
pub fn month_bounds(month: &str) -> Result<(NaiveDate, NaiveDate), ApiError> {
let shape = || ApiError::bad_request(format!("`month` must be YYYY-MM, got `{month}`"));
let (year, rest) = month.split_once('-').ok_or_else(shape)?;
if rest.len() != 2 || year.len() != 4 {
return Err(shape());
}
let first = NaiveDate::parse_from_str(&format!("{month}-01"), "%Y-%m-%d").map_err(|_| shape())?;
let next = if first.month() == 12 {
NaiveDate::from_ymd_opt(first.year() + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(first.year(), first.month() + 1, 1)
};
let last = next.and_then(|next| next.pred_opt()).ok_or_else(shape)?;
Ok((first, last))
}
#[derive(Debug, sqlx::FromRow)]
struct CellRow {
user_id: Uuid,
display_name: String,
department: Option<String>,
date: Option<NaiveDate>,
open: Option<bool>,
worked_seconds: Option<i64>,
}
fn into_rows(cells: Vec<CellRow>) -> Vec<Row> {
let mut rows: Vec<Row> = Vec::new();
for cell in cells {
if rows.last().map(|row| row.user_id) != Some(cell.user_id) {
rows.push(Row {
user_id: cell.user_id,
display_name: cell.display_name,
department: cell.department,
days: Vec::new(),
busiest_seconds: None,
worked_seconds: 0,
});
}
let row = rows.last_mut().expect("a row was just pushed for this person");
let Some(date) = cell.date else { continue };
if let Some(seconds) = cell.worked_seconds {
row.worked_seconds += seconds;
row.busiest_seconds = Some(row.busiest_seconds.map_or(seconds, |busiest: i64| busiest.max(seconds)));
}
row.days.push(Cell {
date,
worked_seconds: cell.worked_seconds,
open: cell.open.unwrap_or(false),
});
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
fn bounds(month: &str) -> (NaiveDate, NaiveDate) {
month_bounds(month).expect("a valid month")
}
fn date(text: &str) -> NaiveDate {
text.parse().expect("a test date")
}
#[test]
fn a_month_ends_where_the_calendar_says() {
assert_eq!(bounds("2026-01"), (date("2026-01-01"), date("2026-01-31")));
assert_eq!(bounds("2026-04"), (date("2026-04-01"), date("2026-04-30")));
assert_eq!(bounds("2026-02"), (date("2026-02-01"), date("2026-02-28")));
assert_eq!(bounds("2024-02"), (date("2024-02-01"), date("2024-02-29")));
}
#[test]
fn december_rolls_into_the_next_year() {
assert_eq!(bounds("2026-12"), (date("2026-12-01"), date("2026-12-31")));
}
#[test]
fn a_month_that_is_not_a_month_is_refused() {
for bad in ["2026", "2026-13", "2026-00", "August", "2026-08-15", "26-08", ""] {
let error = month_bounds(bad).unwrap_err();
assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST, "`{bad}` should be refused");
assert!(error.to_string().contains("YYYY-MM"), "the message should say the shape: {error}");
}
}
fn cell(user: Uuid, name: &str, date: Option<&str>, worked: Option<i64>, open: bool) -> CellRow {
CellRow {
user_id: user,
display_name: name.to_string(),
department: None,
date: date.map(|text| text.parse().expect("a test date")),
open: date.map(|_| open),
worked_seconds: worked,
}
}
#[test]
fn a_person_with_nothing_recorded_is_still_a_row() {
let nobody = Uuid::new_v4();
let rows = into_rows(vec![cell(nobody, "Nobody", None, None, false)]);
assert_eq!(rows.len(), 1);
assert!(rows[0].days.is_empty(), "no days, rather than a day with no date");
assert_eq!(rows[0].worked_seconds, 0);
assert_eq!(rows[0].busiest_seconds, None, "no finished day means no busiest one");
}
#[test]
fn an_open_day_is_a_cell_without_a_total() {
let person = Uuid::new_v4();
let rows = into_rows(vec![
cell(person, "Ann", Some("2026-09-01"), Some(28_800), false),
cell(person, "Ann", Some("2026-09-02"), None, true),
]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].days.len(), 2);
assert_eq!(rows[0].days[1].worked_seconds, None);
assert!(rows[0].days[1].open);
assert_eq!(rows[0].worked_seconds, 28_800, "the open day adds nothing");
assert_eq!(rows[0].busiest_seconds, Some(28_800));
}
#[test]
fn each_persons_days_land_on_their_own_row() {
let ann = Uuid::new_v4();
let bob = Uuid::new_v4();
let rows = into_rows(vec![
cell(ann, "Ann", Some("2026-09-01"), Some(3_600), false),
cell(ann, "Ann", Some("2026-09-02"), Some(7_200), false),
cell(bob, "Bob", Some("2026-09-01"), Some(1_800), false),
]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].days.len(), 2);
assert_eq!(rows[0].worked_seconds, 10_800);
assert_eq!(rows[0].busiest_seconds, Some(7_200), "the longest day, not the last one");
assert_eq!(rows[1].days.len(), 1);
assert_eq!(rows[1].worked_seconds, 1_800);
}
#[test]
fn the_busiest_day_is_the_largest_not_the_latest() {
let person = Uuid::new_v4();
let rows = into_rows(vec![
cell(person, "Ann", Some("2026-09-01"), Some(36_000), false),
cell(person, "Ann", Some("2026-09-02"), Some(3_600), false),
]);
assert_eq!(rows[0].busiest_seconds, Some(36_000));
}
}