1use 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
39const SECONDS_PER_HOUR: i64 = 3600;
42
43const SHORT_DAY_RELIEF_HOURS: i64 = 1;
49
50#[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 Holiday,
57 ShortDay,
59 WorkingWeekend,
62}
63
64#[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 pub fn owes_the_norm(self) -> bool {
86 matches!(self, Self::Work)
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
92pub struct CalendarDay {
93 pub date: NaiveDate,
94 pub kind: CalendarDayKind,
95 pub note: Option<String>,
98}
99
100#[derive(Debug, Clone, Default)]
106pub struct Calendar {
107 days: Vec<CalendarDay>,
109}
110
111impl Calendar {
112 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 pub fn empty() -> Self {
125 Self::default()
126 }
127
128 pub fn from_days(mut days: Vec<CalendarDay>) -> Self {
130 days.sort_by_key(|day| day.date);
131 Self { days }
132 }
133
134 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 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 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 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
182fn is_weekend(date: NaiveDate) -> bool {
184 matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
185}
186
187fn to_seconds(hours: Decimal) -> i64 {
192 (hours * Decimal::from(SECONDS_PER_HOUR)).round().to_i64().unwrap_or(0)
193}
194
195#[derive(Debug, Clone, Copy)]
201pub struct Norm {
202 pub standard_hours: Decimal,
203 pub work_rate: Decimal,
204}
205
206impl Norm {
207 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 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 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#[derive(Debug, Clone, Copy, Serialize)]
249pub struct Progress {
250 pub norm_seconds: i64,
253 pub standard_hours: Decimal,
256 pub work_rate: Decimal,
258}
259
260#[derive(Debug, Deserialize)]
265pub struct YearQuery {
266 pub year: i32,
267}
268
269#[derive(Debug, Serialize)]
271pub struct CalendarYear {
272 pub year: i32,
273 pub days: Vec<CalendarDay>,
274 pub standard_hours: Decimal,
277}
278
279#[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#[derive(Debug, Deserialize)]
294pub struct CalendarYearInput {
295 pub days: Vec<CalendarDayInput>,
296}
297
298#[derive(Debug, Deserialize)]
300pub struct StandardHoursInput {
301 pub standard_hours: Decimal,
302}
303
304#[derive(Debug, Deserialize)]
306pub struct WorkRateInput {
307 pub work_rate: Decimal,
308}
309
310pub 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
326pub 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 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 .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
393pub 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 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
427pub 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 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
473fn 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
480pub 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 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 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 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 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 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 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 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 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}