use axum::{
Json,
extract::{Query, State},
response::IntoResponse,
};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::{
app::AppState,
calendar::{Calendar, Norm, Progress, WorkdayKind},
error::ApiError,
login::CurrentUser,
privacy::{Policy, PrivacyLevel},
};
pub const MAX_RANGE_DAYS: i64 = 400;
#[derive(Debug, Deserialize)]
pub struct Range {
pub from: NaiveDate,
pub to: NaiveDate,
}
#[derive(Debug, Serialize)]
pub struct Day {
pub date: NaiveDate,
pub kind: WorkdayKind,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub worked_seconds: Option<i64>,
pub paused_count: i64,
pub paused_seconds: i64,
pub pauses: Vec<Pause>,
pub tasks: Vec<Task>,
pub norm_seconds: i64,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Pause {
pub id: Uuid,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub duration_seconds: Option<i32>,
pub manual: bool,
pub reason: Option<String>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Task {
pub id: Uuid,
pub name: String,
pub comment: Option<String>,
pub completeness: i16,
pub recorded_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct Days {
pub from: NaiveDate,
pub to: NaiveDate,
pub days: Vec<Day>,
pub privacy_level: PrivacyLevel,
pub not_stored: Vec<&'static str>,
pub progress: Progress,
pub worked_seconds: i64,
}
pub fn not_stored_at(level: PrivacyLevel) -> Vec<&'static str> {
let mut withheld = Vec::new();
if !level.keeps_pause_times() {
withheld.push("pauses");
}
if !level.keeps_tasks() {
withheld.push("tasks");
}
if !level.keeps_free_text() {
withheld.push("free_text");
}
withheld
}
pub async fn days(State(state): State<AppState>, user: CurrentUser, Query(range): Query<Range>) -> Result<impl IntoResponse, ApiError> {
validate_range(&range)?;
Ok(Json(days_for(&state.pool, user.user_id, &range).await?))
}
pub async fn days_for(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Days, ApiError> {
let level = Policy::load(pool).await?.level();
let mut days = load_days(pool, user_id, range).await?;
let calendar = Calendar::load(pool, range.from, range.to).await?;
let norm = Norm::load(pool, user_id).await?;
let away: Vec<NaiveDate> = days.iter().filter(|day| !day.kind.owes_the_norm()).map(|day| day.date).collect();
for day in &mut days {
day.norm_seconds = if day.kind.owes_the_norm() {
calendar.norm_seconds(day.date, norm.standard_hours, norm.work_rate)
} else {
0
};
}
let worked_seconds = days.iter().filter_map(|day| day.worked_seconds).sum();
Ok(Days {
from: range.from,
to: range.to,
progress: Progress {
norm_seconds: norm.for_range(&calendar, range.from, range.to, &away),
standard_hours: norm.standard_hours,
work_rate: norm.work_rate,
},
worked_seconds,
days,
privacy_level: level,
not_stored: not_stored_at(level),
})
}
pub fn validate_range(range: &Range) -> Result<(), ApiError> {
if range.to < range.from {
return Err(ApiError::bad_request("`to` is before `from`"));
}
let span = (range.to - range.from).num_days() + 1;
if span > MAX_RANGE_DAYS {
return Err(ApiError::bad_request(format!(
"a range covers at most {MAX_RANGE_DAYS} days, this one covers {span}"
)));
}
Ok(())
}
async fn load_days(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Vec<Day>, ApiError> {
let workdays: Vec<WorkdayRow> = sqlx::query_as(
r#"
SELECT id, date, kind, started_at, ended_at, paused_count, paused_seconds
FROM workdays
WHERE user_id = $1 AND date BETWEEN $2 AND $3
ORDER BY date
"#,
)
.bind(user_id)
.bind(range.from)
.bind(range.to)
.fetch_all(pool)
.await?;
if workdays.is_empty() {
return Ok(Vec::new());
}
let workday_ids: Vec<Uuid> = workdays.iter().map(|day| day.id).collect();
let pauses: Vec<(Uuid, Pause)> = sqlx::query_as::<_, PauseRow>(
r#"
SELECT workday_id, id, started_at, ended_at, duration_seconds, manual, reason
FROM pauses
WHERE workday_id = ANY($1)
ORDER BY started_at
"#,
)
.bind(&workday_ids)
.fetch_all(pool)
.await?
.into_iter()
.map(PauseRow::split)
.collect();
let tasks: Vec<(NaiveDate, Task)> = sqlx::query_as::<_, TaskRow>(
r#"
SELECT date, id, name, comment, completeness, recorded_at
FROM tasks
WHERE user_id = $1 AND date BETWEEN $2 AND $3
ORDER BY recorded_at
"#,
)
.bind(user_id)
.bind(range.from)
.bind(range.to)
.fetch_all(pool)
.await?
.into_iter()
.map(TaskRow::split)
.collect();
Ok(workdays.into_iter().map(|row| row.into_day(&pauses, &tasks)).collect())
}
#[derive(Debug, sqlx::FromRow)]
struct WorkdayRow {
id: Uuid,
date: NaiveDate,
kind: WorkdayKind,
started_at: DateTime<Utc>,
ended_at: Option<DateTime<Utc>>,
paused_count: Option<i32>,
paused_seconds: Option<i32>,
}
impl WorkdayRow {
fn into_day(self, pauses: &[(Uuid, Pause)], tasks: &[(NaiveDate, Task)]) -> Day {
let own_pauses: Vec<Pause> = pauses.iter().filter(|(id, _)| *id == self.id).map(|(_, pause)| pause.clone_row()).collect();
let own_tasks: Vec<Task> = tasks.iter().filter(|(date, _)| *date == self.date).map(|(_, task)| task.clone_row()).collect();
let (paused_count, paused_seconds) = if own_pauses.is_empty() && (self.paused_count.is_some() || self.paused_seconds.is_some()) {
(i64::from(self.paused_count.unwrap_or(0)), i64::from(self.paused_seconds.unwrap_or(0)))
} else {
let seconds = own_pauses.iter().filter_map(|pause| pause.duration_seconds).map(i64::from).sum();
(own_pauses.len() as i64, seconds)
};
let worked_seconds = self.ended_at.map(|ended| ((ended - self.started_at).num_seconds() - paused_seconds).max(0));
Day {
date: self.date,
kind: self.kind,
started_at: self.started_at,
ended_at: self.ended_at,
worked_seconds,
paused_count,
paused_seconds,
pauses: own_pauses,
tasks: own_tasks,
norm_seconds: 0,
}
}
}
#[derive(Debug, sqlx::FromRow)]
struct PauseRow {
workday_id: Uuid,
id: Uuid,
started_at: DateTime<Utc>,
ended_at: Option<DateTime<Utc>>,
duration_seconds: Option<i32>,
manual: bool,
reason: Option<String>,
}
impl PauseRow {
fn split(self) -> (Uuid, Pause) {
(
self.workday_id,
Pause {
id: self.id,
started_at: self.started_at,
ended_at: self.ended_at,
duration_seconds: self.duration_seconds,
manual: self.manual,
reason: self.reason,
},
)
}
}
impl Pause {
fn clone_row(&self) -> Self {
Self {
id: self.id,
started_at: self.started_at,
ended_at: self.ended_at,
duration_seconds: self.duration_seconds,
manual: self.manual,
reason: self.reason.clone(),
}
}
}
#[derive(Debug, sqlx::FromRow)]
struct TaskRow {
date: NaiveDate,
id: Uuid,
name: String,
comment: Option<String>,
completeness: i16,
recorded_at: DateTime<Utc>,
}
impl TaskRow {
fn split(self) -> (NaiveDate, Task) {
(
self.date,
Task {
id: self.id,
name: self.name,
comment: self.comment,
completeness: self.completeness,
recorded_at: self.recorded_at,
},
)
}
}
impl Task {
fn clone_row(&self) -> Self {
Self {
id: self.id,
name: self.name.clone(),
comment: self.comment.clone(),
completeness: self.completeness,
recorded_at: self.recorded_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn range(from: &str, to: &str) -> Range {
Range {
from: from.parse().expect("a test date"),
to: to.parse().expect("a test date"),
}
}
#[test]
fn a_single_day_is_a_valid_range() {
assert!(validate_range(&range("2026-08-27", "2026-08-27")).is_ok());
}
#[test]
fn a_backwards_range_is_refused() {
let error = validate_range(&range("2026-08-27", "2026-08-01")).unwrap_err();
assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
assert!(error.to_string().contains("before"), "the message should say what is wrong: {error}");
}
#[test]
fn the_range_has_a_ceiling() {
let widest = range("2026-01-01", "2027-02-04");
assert_eq!((widest.to - widest.from).num_days() + 1, MAX_RANGE_DAYS);
assert!(validate_range(&widest).is_ok(), "exactly {MAX_RANGE_DAYS} days is allowed");
let too_wide = range("2026-01-01", "2027-02-05");
let error = validate_range(&too_wide).unwrap_err();
assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
assert!(error.to_string().contains("401"), "the message should name the span asked for: {error}");
}
#[test]
fn a_level_names_what_it_withholds() {
assert!(not_stored_at(PrivacyLevel::Full).is_empty());
assert_eq!(not_stored_at(PrivacyLevel::Moderate), vec!["free_text"]);
assert_eq!(not_stored_at(PrivacyLevel::Coarse), vec!["pauses", "tasks", "free_text"]);
}
}