use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::{Datelike, NaiveDate, Weekday};
use rust_decimal::{Decimal, prelude::ToPrimitive};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::{app::AppState, audit, error::ApiError, login::CurrentUser, me::Range};
const SECONDS_PER_HOUR: i64 = 3600;
const SHORT_DAY_RELIEF_HOURS: i64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "calendar_day_kind", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum CalendarDayKind {
Holiday,
ShortDay,
WorkingWeekend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "workday_kind", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum WorkdayKind {
#[default]
Work,
Vacation,
Sick,
DayOff,
}
impl WorkdayKind {
pub fn owes_the_norm(self) -> bool {
matches!(self, Self::Work)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct CalendarDay {
pub date: NaiveDate,
pub kind: CalendarDayKind,
pub note: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Calendar {
days: Vec<CalendarDay>,
}
impl Calendar {
pub async fn load(pool: &PgPool, from: NaiveDate, to: NaiveDate) -> Result<Self, ApiError> {
let days: Vec<CalendarDay> = sqlx::query_as("SELECT date, kind, note FROM calendar_days WHERE date BETWEEN $1 AND $2 ORDER BY date")
.bind(from)
.bind(to)
.fetch_all(pool)
.await?;
Ok(Self { days })
}
pub fn empty() -> Self {
Self::default()
}
pub fn from_days(mut days: Vec<CalendarDay>) -> Self {
days.sort_by_key(|day| day.date);
Self { days }
}
pub fn days(&self) -> &[CalendarDay] {
&self.days
}
fn kind_of(&self, date: NaiveDate) -> Option<CalendarDayKind> {
self.days.binary_search_by_key(&date, |day| day.date).ok().map(|at| self.days[at].kind)
}
pub fn hours_on(&self, date: NaiveDate, standard_hours: Decimal) -> Decimal {
match self.kind_of(date) {
Some(CalendarDayKind::Holiday) => Decimal::ZERO,
Some(CalendarDayKind::ShortDay) => (standard_hours - Decimal::from(SHORT_DAY_RELIEF_HOURS)).max(Decimal::ZERO),
Some(CalendarDayKind::WorkingWeekend) => standard_hours,
None if is_weekend(date) => Decimal::ZERO,
None => standard_hours,
}
}
pub fn norm_seconds(&self, date: NaiveDate, standard_hours: Decimal, work_rate: Decimal) -> i64 {
to_seconds(self.hours_on(date, standard_hours) * work_rate)
}
pub fn norm_seconds_over(&self, from: NaiveDate, to: NaiveDate, standard_hours: Decimal, work_rate: Decimal) -> i64 {
let mut total = 0;
let mut date = from;
while date <= to {
total += self.norm_seconds(date, standard_hours, work_rate);
let Some(next) = date.succ_opt() else { break };
date = next;
}
total
}
}
fn is_weekend(date: NaiveDate) -> bool {
matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
}
fn to_seconds(hours: Decimal) -> i64 {
(hours * Decimal::from(SECONDS_PER_HOUR)).round().to_i64().unwrap_or(0)
}
#[derive(Debug, Clone, Copy)]
pub struct Norm {
pub standard_hours: Decimal,
pub work_rate: Decimal,
}
impl Norm {
pub async fn load(pool: &PgPool, user_id: Uuid) -> Result<Self, ApiError> {
let standard_hours = Self::standard_hours(pool).await?;
let work_rate: Decimal = sqlx::query_scalar("SELECT work_rate FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?
.unwrap_or(Decimal::ONE);
Ok(Self { standard_hours, work_rate })
}
pub async fn standard_hours(pool: &PgPool) -> Result<Decimal, ApiError> {
Ok(sqlx::query_scalar("SELECT standard_hours FROM settings WHERE singleton")
.fetch_one(pool)
.await?)
}
pub fn for_range(&self, calendar: &Calendar, from: NaiveDate, to: NaiveDate, away: &[NaiveDate]) -> i64 {
let full = calendar.norm_seconds_over(from, to, self.standard_hours, self.work_rate);
let excused: i64 = away
.iter()
.filter(|date| **date >= from && **date <= to)
.map(|date| calendar.norm_seconds(*date, self.standard_hours, self.work_rate))
.sum();
(full - excused).max(0)
}
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct Progress {
pub norm_seconds: i64,
pub standard_hours: Decimal,
pub work_rate: Decimal,
}
#[derive(Debug, Deserialize)]
pub struct YearQuery {
pub year: i32,
}
#[derive(Debug, Serialize)]
pub struct CalendarYear {
pub year: i32,
pub days: Vec<CalendarDay>,
pub standard_hours: Decimal,
}
#[derive(Debug, Deserialize)]
pub struct CalendarDayInput {
pub date: NaiveDate,
pub kind: CalendarDayKind,
#[serde(default)]
pub note: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CalendarYearInput {
pub days: Vec<CalendarDayInput>,
}
#[derive(Debug, Deserialize)]
pub struct StandardHoursInput {
pub standard_hours: Decimal,
}
#[derive(Debug, Deserialize)]
pub struct WorkRateInput {
pub work_rate: Decimal,
}
pub async fn year(State(state): State<AppState>, _user: CurrentUser, Query(query): Query<YearQuery>) -> Result<impl IntoResponse, ApiError> {
let (from, to) = year_bounds(query.year)?;
let calendar = Calendar::load(&state.pool, from, to).await?;
Ok(Json(CalendarYear {
year: query.year,
days: calendar.days,
standard_hours: Norm::standard_hours(&state.pool).await?,
}))
}
pub async fn put_year(
State(state): State<AppState>,
user: CurrentUser,
Query(query): Query<YearQuery>,
Json(input): Json<CalendarYearInput>,
) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let (from, to) = year_bounds(query.year)?;
for (index, day) in input.days.iter().enumerate() {
if day.date < from || day.date > to {
return Err(ApiError::bad_request(format!("days[{index}]: {} is not in {}", day.date, query.year)));
}
}
let mut tx = state.pool.begin().await?;
sqlx::query("DELETE FROM calendar_days WHERE date BETWEEN $1 AND $2")
.bind(from)
.bind(to)
.execute(&mut *tx)
.await?;
for day in &input.days {
sqlx::query("INSERT INTO calendar_days (date, kind, note) VALUES ($1, $2, $3)")
.bind(day.date)
.bind(day.kind)
.bind(day.note.as_deref().map(str::trim).filter(|note| !note.is_empty()))
.execute(&mut *tx)
.await
.map_err(|error| match &error {
sqlx::Error::Database(db) if db.is_unique_violation() => ApiError::bad_request(format!("{} appears twice", day.date)),
_ => ApiError::from(error),
})?;
}
tx.commit().await?;
tracing::info!(year = query.year, days = input.days.len(), by = %user.user_id, "replaced a year of the calendar");
audit::Entry::new(audit::action::CALENDAR_YEAR_REPLACED)
.by(user.user_id)
.by_email(&user.email)
.with(serde_json::json!({ "year": query.year, "days": input.days.len() }))
.record(&state.pool)
.await;
let calendar = Calendar::load(&state.pool, from, to).await?;
Ok((
StatusCode::OK,
Json(CalendarYear {
year: query.year,
days: calendar.days,
standard_hours: Norm::standard_hours(&state.pool).await?,
}),
))
}
pub async fn put_standard_hours(
State(state): State<AppState>,
user: CurrentUser,
Json(input): Json<StandardHoursInput>,
) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
if input.standard_hours <= Decimal::ZERO || input.standard_hours > Decimal::from(24) {
return Err(ApiError::bad_request("a full day is more than zero hours and at most 24"));
}
let previous: Decimal = sqlx::query_scalar("SELECT standard_hours FROM settings WHERE singleton")
.fetch_one(&state.pool)
.await?;
sqlx::query("UPDATE settings SET standard_hours = $1 WHERE singleton")
.bind(input.standard_hours)
.execute(&state.pool)
.await?;
tracing::info!(from = %previous, to = %input.standard_hours, by = %user.user_id, "changed the installation's full day");
audit::Entry::new(audit::action::STANDARD_HOURS_CHANGED)
.by(user.user_id)
.by_email(&user.email)
.with(serde_json::json!({ "from": previous, "to": input.standard_hours }))
.record(&state.pool)
.await;
Ok((StatusCode::OK, Json(serde_json::json!({ "standard_hours": input.standard_hours }))))
}
pub async fn put_work_rate(
State(state): State<AppState>,
user: CurrentUser,
Path(target): Path<Uuid>,
Json(input): Json<WorkRateInput>,
) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
if input.work_rate < Decimal::ZERO || input.work_rate > Decimal::from(2) {
return Err(ApiError::bad_request("a share of a full day is between 0 and 2"));
}
let previous: Option<Decimal> = sqlx::query_scalar(
"UPDATE users SET work_rate = $1 FROM (SELECT work_rate FROM users WHERE id = $2) AS before
WHERE users.id = $2 RETURNING before.work_rate",
)
.bind(input.work_rate)
.bind(target)
.fetch_optional(&state.pool)
.await?;
let Some(previous) = previous else {
return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
};
tracing::info!(user = %target, from = %previous, to = %input.work_rate, by = %user.user_id, "changed a work rate");
audit::Entry::new(audit::action::WORK_RATE_CHANGED)
.by(user.user_id)
.by_email(&user.email)
.on(target)
.with(serde_json::json!({ "from": previous, "to": input.work_rate }))
.record(&state.pool)
.await;
Ok((StatusCode::OK, Json(serde_json::json!({ "work_rate": input.work_rate }))))
}
fn year_bounds(year: i32) -> Result<(NaiveDate, NaiveDate), ApiError> {
let from = NaiveDate::from_ymd_opt(year, 1, 1).ok_or_else(|| ApiError::bad_request(format!("{year} is not a year")))?;
let to = NaiveDate::from_ymd_opt(year, 12, 31).ok_or_else(|| ApiError::bad_request(format!("{year} is not a year")))?;
Ok((from, to))
}
pub async fn away_dates(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Vec<NaiveDate>, ApiError> {
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")
.bind(user_id)
.bind(range.from)
.bind(range.to)
.fetch_all(pool)
.await?;
Ok(dates)
}
#[cfg(test)]
mod tests {
use super::*;
fn date(text: &str) -> NaiveDate {
text.parse().expect("a test date")
}
fn day(text: &str, kind: CalendarDayKind) -> CalendarDay {
CalendarDay {
date: date(text),
kind,
note: None,
}
}
fn eight() -> Decimal {
Decimal::from(8)
}
#[test]
fn an_empty_calendar_works_the_weekdays() {
let calendar = Calendar::empty();
assert_eq!(calendar.hours_on(date("2026-09-14"), eight()), eight());
assert_eq!(calendar.hours_on(date("2026-09-19"), eight()), Decimal::ZERO);
assert_eq!(calendar.hours_on(date("2026-09-20"), eight()), Decimal::ZERO);
}
#[test]
fn a_holiday_is_worth_nothing_and_an_eve_an_hour_less() {
let calendar = Calendar::from_days(vec![day("2026-01-01", CalendarDayKind::Holiday), day("2025-12-31", CalendarDayKind::ShortDay)]);
assert_eq!(calendar.hours_on(date("2026-01-01"), eight()), Decimal::ZERO);
assert_eq!(calendar.hours_on(date("2025-12-31"), eight()), Decimal::from(7));
}
#[test]
fn a_working_weekend_is_a_full_day() {
let saturday = date("2026-11-07");
assert_eq!(saturday.weekday(), Weekday::Sat);
assert_eq!(Calendar::empty().hours_on(saturday, eight()), Decimal::ZERO);
let calendar = Calendar::from_days(vec![day("2026-11-07", CalendarDayKind::WorkingWeekend)]);
assert_eq!(calendar.hours_on(saturday, eight()), eight());
}
#[test]
fn the_rate_multiplies_the_shortened_day_not_the_other_way() {
let calendar = Calendar::from_days(vec![day("2026-12-31", CalendarDayKind::ShortDay)]);
let half = Decimal::new(5, 1);
assert_eq!(calendar.norm_seconds(date("2026-12-31"), eight(), half), 3 * 3600 + 1800);
}
#[test]
fn a_week_sums_its_days() {
let total = Calendar::empty().norm_seconds_over(date("2026-09-14"), date("2026-09-20"), eight(), Decimal::ONE);
assert_eq!(total, 5 * 8 * 3600);
}
#[test]
fn a_holiday_takes_its_day_out_of_the_week() {
let calendar = Calendar::from_days(vec![day("2026-09-16", CalendarDayKind::Holiday)]);
let total = calendar.norm_seconds_over(date("2026-09-14"), date("2026-09-20"), eight(), Decimal::ONE);
assert_eq!(total, 4 * 8 * 3600, "the week owes four days once a Wednesday is a holiday");
}
#[test]
fn leave_is_excused_rather_than_counted_as_worked() {
let norm = Norm {
standard_hours: eight(),
work_rate: Decimal::ONE,
};
let calendar = Calendar::empty();
let (monday, sunday) = (date("2026-09-14"), date("2026-09-20"));
assert_eq!(norm.for_range(&calendar, monday, sunday, &[]), 5 * 8 * 3600);
assert_eq!(
norm.for_range(&calendar, monday, sunday, &[date("2026-09-15"), date("2026-09-16")]),
3 * 8 * 3600,
"two days of leave are not owed"
);
}
#[test]
fn a_weekend_of_leave_excuses_nothing_it_did_not_owe() {
let norm = Norm {
standard_hours: eight(),
work_rate: Decimal::ONE,
};
let total = norm.for_range(&Calendar::empty(), date("2026-09-14"), date("2026-09-20"), &[date("2026-09-19")]);
assert_eq!(total, 5 * 8 * 3600);
}
#[test]
fn a_day_the_employee_was_away_owes_nothing() {
assert!(WorkdayKind::Work.owes_the_norm());
for away in [WorkdayKind::Vacation, WorkdayKind::Sick, WorkdayKind::DayOff] {
assert!(!away.owes_the_norm(), "{away:?} owes no hours");
}
}
#[test]
fn the_wire_names_are_snake_case() {
assert_eq!(serde_json::to_string(&WorkdayKind::DayOff).unwrap(), "\"day_off\"");
assert_eq!(serde_json::to_string(&CalendarDayKind::WorkingWeekend).unwrap(), "\"working_weekend\"");
assert_eq!(serde_json::to_string(&CalendarDayKind::ShortDay).unwrap(), "\"short_day\"");
}
#[test]
fn an_agent_that_says_nothing_worked() {
assert_eq!(WorkdayKind::default(), WorkdayKind::Work);
assert_eq!(serde_json::from_str::<WorkdayKind>("\"work\"").unwrap(), WorkdayKind::Work);
}
#[test]
fn a_year_outside_the_calendar_is_refused() {
assert!(year_bounds(2026).is_ok());
let error = year_bounds(i32::MAX).unwrap_err();
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
}
}