1use axum::{
25 Json,
26 extract::{Query, State},
27 response::IntoResponse,
28};
29use chrono::{DateTime, NaiveDate, Utc};
30use serde::{Deserialize, Serialize};
31use sqlx::PgPool;
32use uuid::Uuid;
33
34use crate::{
35 app::AppState,
36 error::ApiError,
37 login::CurrentUser,
38 privacy::{Policy, PrivacyLevel},
39};
40
41pub const MAX_RANGE_DAYS: i64 = 400;
47
48#[derive(Debug, Deserialize)]
52pub struct Range {
53 pub from: NaiveDate,
54 pub to: NaiveDate,
55}
56
57#[derive(Debug, Serialize)]
63pub struct Day {
64 pub date: NaiveDate,
65 pub started_at: DateTime<Utc>,
66 pub ended_at: Option<DateTime<Utc>>,
68 pub worked_seconds: Option<i64>,
72 pub paused_count: i64,
76 pub paused_seconds: i64,
77 pub pauses: Vec<Pause>,
78 pub tasks: Vec<Task>,
79}
80
81#[derive(Debug, Serialize, sqlx::FromRow)]
82pub struct Pause {
83 pub id: Uuid,
84 pub started_at: DateTime<Utc>,
85 pub ended_at: Option<DateTime<Utc>>,
86 pub duration_seconds: Option<i32>,
87 pub manual: bool,
89 pub reason: Option<String>,
91}
92
93#[derive(Debug, Serialize, sqlx::FromRow)]
94pub struct Task {
95 pub id: Uuid,
96 pub name: String,
97 pub comment: Option<String>,
98 pub completeness: i16,
99 pub recorded_at: DateTime<Utc>,
100}
101
102#[derive(Debug, Serialize)]
104pub struct Days {
105 pub from: NaiveDate,
106 pub to: NaiveDate,
107 pub days: Vec<Day>,
108 pub privacy_level: PrivacyLevel,
112 pub not_stored: Vec<&'static str>,
116}
117
118pub fn not_stored_at(level: PrivacyLevel) -> Vec<&'static str> {
120 let mut withheld = Vec::new();
121 if !level.keeps_pause_times() {
122 withheld.push("pauses");
123 }
124 if !level.keeps_tasks() {
125 withheld.push("tasks");
126 }
127 if !level.keeps_free_text() {
128 withheld.push("free_text");
129 }
130 withheld
131}
132
133pub async fn days(State(state): State<AppState>, user: CurrentUser, Query(range): Query<Range>) -> Result<impl IntoResponse, ApiError> {
135 validate_range(&range)?;
136 Ok(Json(days_for(&state.pool, user.user_id, &range).await?))
137}
138
139pub async fn days_for(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Days, ApiError> {
145 let level = Policy::load(pool).await?.level();
146 let days = load_days(pool, user_id, range).await?;
147
148 Ok(Days {
149 from: range.from,
150 to: range.to,
151 days,
152 privacy_level: level,
153 not_stored: not_stored_at(level),
154 })
155}
156
157pub fn validate_range(range: &Range) -> Result<(), ApiError> {
162 if range.to < range.from {
163 return Err(ApiError::bad_request("`to` is before `from`"));
164 }
165 let span = (range.to - range.from).num_days() + 1;
167 if span > MAX_RANGE_DAYS {
168 return Err(ApiError::bad_request(format!(
169 "a range covers at most {MAX_RANGE_DAYS} days, this one covers {span}"
170 )));
171 }
172 Ok(())
173}
174
175async fn load_days(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Vec<Day>, ApiError> {
182 let workdays: Vec<WorkdayRow> = sqlx::query_as(
183 r#"
184 SELECT id, date, started_at, ended_at, paused_count, paused_seconds
185 FROM workdays
186 WHERE user_id = $1 AND date BETWEEN $2 AND $3
187 ORDER BY date
188 "#,
189 )
190 .bind(user_id)
191 .bind(range.from)
192 .bind(range.to)
193 .fetch_all(pool)
194 .await?;
195
196 if workdays.is_empty() {
197 return Ok(Vec::new());
198 }
199
200 let workday_ids: Vec<Uuid> = workdays.iter().map(|day| day.id).collect();
201
202 let pauses: Vec<(Uuid, Pause)> = sqlx::query_as::<_, PauseRow>(
203 r#"
204 SELECT workday_id, id, started_at, ended_at, duration_seconds, manual, reason
205 FROM pauses
206 WHERE workday_id = ANY($1)
207 ORDER BY started_at
208 "#,
209 )
210 .bind(&workday_ids)
211 .fetch_all(pool)
212 .await?
213 .into_iter()
214 .map(PauseRow::split)
215 .collect();
216
217 let tasks: Vec<(NaiveDate, Task)> = sqlx::query_as::<_, TaskRow>(
221 r#"
222 SELECT date, id, name, comment, completeness, recorded_at
223 FROM tasks
224 WHERE user_id = $1 AND date BETWEEN $2 AND $3
225 ORDER BY recorded_at
226 "#,
227 )
228 .bind(user_id)
229 .bind(range.from)
230 .bind(range.to)
231 .fetch_all(pool)
232 .await?
233 .into_iter()
234 .map(TaskRow::split)
235 .collect();
236
237 Ok(workdays.into_iter().map(|row| row.into_day(&pauses, &tasks)).collect())
238}
239
240#[derive(Debug, sqlx::FromRow)]
242struct WorkdayRow {
243 id: Uuid,
244 date: NaiveDate,
245 started_at: DateTime<Utc>,
246 ended_at: Option<DateTime<Utc>>,
247 paused_count: Option<i32>,
248 paused_seconds: Option<i32>,
249}
250
251impl WorkdayRow {
252 fn into_day(self, pauses: &[(Uuid, Pause)], tasks: &[(NaiveDate, Task)]) -> Day {
253 let own_pauses: Vec<Pause> = pauses.iter().filter(|(id, _)| *id == self.id).map(|(_, pause)| pause.clone_row()).collect();
254 let own_tasks: Vec<Task> = tasks.iter().filter(|(date, _)| *date == self.date).map(|(_, task)| task.clone_row()).collect();
255
256 let (paused_count, paused_seconds) = if own_pauses.is_empty() && (self.paused_count.is_some() || self.paused_seconds.is_some()) {
262 (i64::from(self.paused_count.unwrap_or(0)), i64::from(self.paused_seconds.unwrap_or(0)))
263 } else {
264 let seconds = own_pauses.iter().filter_map(|pause| pause.duration_seconds).map(i64::from).sum();
265 (own_pauses.len() as i64, seconds)
266 };
267
268 let worked_seconds = self.ended_at.map(|ended| ((ended - self.started_at).num_seconds() - paused_seconds).max(0));
269
270 Day {
271 date: self.date,
272 started_at: self.started_at,
273 ended_at: self.ended_at,
274 worked_seconds,
275 paused_count,
276 paused_seconds,
277 pauses: own_pauses,
278 tasks: own_tasks,
279 }
280 }
281}
282
283#[derive(Debug, sqlx::FromRow)]
285struct PauseRow {
286 workday_id: Uuid,
287 id: Uuid,
288 started_at: DateTime<Utc>,
289 ended_at: Option<DateTime<Utc>>,
290 duration_seconds: Option<i32>,
291 manual: bool,
292 reason: Option<String>,
293}
294
295impl PauseRow {
296 fn split(self) -> (Uuid, Pause) {
297 (
298 self.workday_id,
299 Pause {
300 id: self.id,
301 started_at: self.started_at,
302 ended_at: self.ended_at,
303 duration_seconds: self.duration_seconds,
304 manual: self.manual,
305 reason: self.reason,
306 },
307 )
308 }
309}
310
311impl Pause {
312 fn clone_row(&self) -> Self {
313 Self {
314 id: self.id,
315 started_at: self.started_at,
316 ended_at: self.ended_at,
317 duration_seconds: self.duration_seconds,
318 manual: self.manual,
319 reason: self.reason.clone(),
320 }
321 }
322}
323
324#[derive(Debug, sqlx::FromRow)]
326struct TaskRow {
327 date: NaiveDate,
328 id: Uuid,
329 name: String,
330 comment: Option<String>,
331 completeness: i16,
332 recorded_at: DateTime<Utc>,
333}
334
335impl TaskRow {
336 fn split(self) -> (NaiveDate, Task) {
337 (
338 self.date,
339 Task {
340 id: self.id,
341 name: self.name,
342 comment: self.comment,
343 completeness: self.completeness,
344 recorded_at: self.recorded_at,
345 },
346 )
347 }
348}
349
350impl Task {
351 fn clone_row(&self) -> Self {
352 Self {
353 id: self.id,
354 name: self.name.clone(),
355 comment: self.comment.clone(),
356 completeness: self.completeness,
357 recorded_at: self.recorded_at,
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 fn range(from: &str, to: &str) -> Range {
367 Range {
368 from: from.parse().expect("a test date"),
369 to: to.parse().expect("a test date"),
370 }
371 }
372
373 #[test]
374 fn a_single_day_is_a_valid_range() {
375 assert!(validate_range(&range("2026-08-27", "2026-08-27")).is_ok());
379 }
380
381 #[test]
382 fn a_backwards_range_is_refused() {
383 let error = validate_range(&range("2026-08-27", "2026-08-01")).unwrap_err();
384 assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
385 assert!(error.to_string().contains("before"), "the message should say what is wrong: {error}");
386 }
387
388 #[test]
389 fn the_range_has_a_ceiling() {
390 let widest = range("2026-01-01", "2027-02-04");
393 assert_eq!((widest.to - widest.from).num_days() + 1, MAX_RANGE_DAYS);
394 assert!(validate_range(&widest).is_ok(), "exactly {MAX_RANGE_DAYS} days is allowed");
395
396 let too_wide = range("2026-01-01", "2027-02-05");
397 let error = validate_range(&too_wide).unwrap_err();
398 assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
399 assert!(error.to_string().contains("401"), "the message should name the span asked for: {error}");
400 }
401
402 #[test]
403 fn a_level_names_what_it_withholds() {
404 assert!(not_stored_at(PrivacyLevel::Full).is_empty());
408 assert_eq!(not_stored_at(PrivacyLevel::Moderate), vec!["free_text"]);
409 assert_eq!(not_stored_at(PrivacyLevel::Coarse), vec!["pauses", "tasks", "free_text"]);
410 }
411}