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 calendar::{Calendar, Norm, Progress, WorkdayKind},
37 error::ApiError,
38 login::CurrentUser,
39 privacy::{Policy, PrivacyLevel},
40};
41
42pub const MAX_RANGE_DAYS: i64 = 400;
48
49#[derive(Debug, Deserialize)]
53pub struct Range {
54 pub from: NaiveDate,
55 pub to: NaiveDate,
56}
57
58#[derive(Debug, Serialize)]
64pub struct Day {
65 pub date: NaiveDate,
66 pub kind: WorkdayKind,
69 pub started_at: DateTime<Utc>,
70 pub ended_at: Option<DateTime<Utc>>,
72 pub worked_seconds: Option<i64>,
76 pub paused_count: i64,
80 pub paused_seconds: i64,
81 pub pauses: Vec<Pause>,
82 pub tasks: Vec<Task>,
83 pub norm_seconds: i64,
87}
88
89#[derive(Debug, Serialize, sqlx::FromRow)]
90pub struct Pause {
91 pub id: Uuid,
92 pub started_at: DateTime<Utc>,
93 pub ended_at: Option<DateTime<Utc>>,
94 pub duration_seconds: Option<i32>,
95 pub manual: bool,
97 pub reason: Option<String>,
99}
100
101#[derive(Debug, Serialize, sqlx::FromRow)]
102pub struct Task {
103 pub id: Uuid,
104 pub name: String,
105 pub comment: Option<String>,
106 pub completeness: i16,
107 pub recorded_at: DateTime<Utc>,
108}
109
110#[derive(Debug, Serialize)]
112pub struct Days {
113 pub from: NaiveDate,
114 pub to: NaiveDate,
115 pub days: Vec<Day>,
116 pub privacy_level: PrivacyLevel,
120 pub not_stored: Vec<&'static str>,
124 pub progress: Progress,
127 pub worked_seconds: i64,
130}
131
132pub fn not_stored_at(level: PrivacyLevel) -> Vec<&'static str> {
134 let mut withheld = Vec::new();
135 if !level.keeps_pause_times() {
136 withheld.push("pauses");
137 }
138 if !level.keeps_tasks() {
139 withheld.push("tasks");
140 }
141 if !level.keeps_free_text() {
142 withheld.push("free_text");
143 }
144 withheld
145}
146
147pub async fn days(State(state): State<AppState>, user: CurrentUser, Query(range): Query<Range>) -> Result<impl IntoResponse, ApiError> {
149 validate_range(&range)?;
150 Ok(Json(days_for(&state.pool, user.user_id, &range).await?))
151}
152
153pub async fn days_for(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Days, ApiError> {
159 let level = Policy::load(pool).await?.level();
160 let mut days = load_days(pool, user_id, range).await?;
161
162 let calendar = Calendar::load(pool, range.from, range.to).await?;
166 let norm = Norm::load(pool, user_id).await?;
167
168 let away: Vec<NaiveDate> = days.iter().filter(|day| !day.kind.owes_the_norm()).map(|day| day.date).collect();
171
172 for day in &mut days {
173 day.norm_seconds = if day.kind.owes_the_norm() {
174 calendar.norm_seconds(day.date, norm.standard_hours, norm.work_rate)
175 } else {
176 0
177 };
178 }
179
180 let worked_seconds = days.iter().filter_map(|day| day.worked_seconds).sum();
181
182 Ok(Days {
183 from: range.from,
184 to: range.to,
185 progress: Progress {
186 norm_seconds: norm.for_range(&calendar, range.from, range.to, &away),
187 standard_hours: norm.standard_hours,
188 work_rate: norm.work_rate,
189 },
190 worked_seconds,
191 days,
192 privacy_level: level,
193 not_stored: not_stored_at(level),
194 })
195}
196
197pub fn validate_range(range: &Range) -> Result<(), ApiError> {
202 if range.to < range.from {
203 return Err(ApiError::bad_request("`to` is before `from`"));
204 }
205 let span = (range.to - range.from).num_days() + 1;
207 if span > MAX_RANGE_DAYS {
208 return Err(ApiError::bad_request(format!(
209 "a range covers at most {MAX_RANGE_DAYS} days, this one covers {span}"
210 )));
211 }
212 Ok(())
213}
214
215async fn load_days(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Vec<Day>, ApiError> {
222 let workdays: Vec<WorkdayRow> = sqlx::query_as(
223 r#"
224 SELECT id, date, kind, started_at, ended_at, paused_count, paused_seconds
225 FROM workdays
226 WHERE user_id = $1 AND date BETWEEN $2 AND $3
227 ORDER BY date
228 "#,
229 )
230 .bind(user_id)
231 .bind(range.from)
232 .bind(range.to)
233 .fetch_all(pool)
234 .await?;
235
236 if workdays.is_empty() {
237 return Ok(Vec::new());
238 }
239
240 let workday_ids: Vec<Uuid> = workdays.iter().map(|day| day.id).collect();
241
242 let pauses: Vec<(Uuid, Pause)> = sqlx::query_as::<_, PauseRow>(
243 r#"
244 SELECT workday_id, id, started_at, ended_at, duration_seconds, manual, reason
245 FROM pauses
246 WHERE workday_id = ANY($1)
247 ORDER BY started_at
248 "#,
249 )
250 .bind(&workday_ids)
251 .fetch_all(pool)
252 .await?
253 .into_iter()
254 .map(PauseRow::split)
255 .collect();
256
257 let tasks: Vec<(NaiveDate, Task)> = sqlx::query_as::<_, TaskRow>(
261 r#"
262 SELECT date, id, name, comment, completeness, recorded_at
263 FROM tasks
264 WHERE user_id = $1 AND date BETWEEN $2 AND $3
265 ORDER BY recorded_at
266 "#,
267 )
268 .bind(user_id)
269 .bind(range.from)
270 .bind(range.to)
271 .fetch_all(pool)
272 .await?
273 .into_iter()
274 .map(TaskRow::split)
275 .collect();
276
277 Ok(workdays.into_iter().map(|row| row.into_day(&pauses, &tasks)).collect())
278}
279
280#[derive(Debug, sqlx::FromRow)]
282struct WorkdayRow {
283 id: Uuid,
284 date: NaiveDate,
285 kind: WorkdayKind,
286 started_at: DateTime<Utc>,
287 ended_at: Option<DateTime<Utc>>,
288 paused_count: Option<i32>,
289 paused_seconds: Option<i32>,
290}
291
292impl WorkdayRow {
293 fn into_day(self, pauses: &[(Uuid, Pause)], tasks: &[(NaiveDate, Task)]) -> Day {
294 let own_pauses: Vec<Pause> = pauses.iter().filter(|(id, _)| *id == self.id).map(|(_, pause)| pause.clone_row()).collect();
295 let own_tasks: Vec<Task> = tasks.iter().filter(|(date, _)| *date == self.date).map(|(_, task)| task.clone_row()).collect();
296
297 let (paused_count, paused_seconds) = if own_pauses.is_empty() && (self.paused_count.is_some() || self.paused_seconds.is_some()) {
303 (i64::from(self.paused_count.unwrap_or(0)), i64::from(self.paused_seconds.unwrap_or(0)))
304 } else {
305 let seconds = own_pauses.iter().filter_map(|pause| pause.duration_seconds).map(i64::from).sum();
306 (own_pauses.len() as i64, seconds)
307 };
308
309 let worked_seconds = self.ended_at.map(|ended| ((ended - self.started_at).num_seconds() - paused_seconds).max(0));
310
311 Day {
312 date: self.date,
313 kind: self.kind,
314 started_at: self.started_at,
315 ended_at: self.ended_at,
316 worked_seconds,
317 paused_count,
318 paused_seconds,
319 pauses: own_pauses,
320 tasks: own_tasks,
321 norm_seconds: 0,
324 }
325 }
326}
327
328#[derive(Debug, sqlx::FromRow)]
330struct PauseRow {
331 workday_id: Uuid,
332 id: Uuid,
333 started_at: DateTime<Utc>,
334 ended_at: Option<DateTime<Utc>>,
335 duration_seconds: Option<i32>,
336 manual: bool,
337 reason: Option<String>,
338}
339
340impl PauseRow {
341 fn split(self) -> (Uuid, Pause) {
342 (
343 self.workday_id,
344 Pause {
345 id: self.id,
346 started_at: self.started_at,
347 ended_at: self.ended_at,
348 duration_seconds: self.duration_seconds,
349 manual: self.manual,
350 reason: self.reason,
351 },
352 )
353 }
354}
355
356impl Pause {
357 fn clone_row(&self) -> Self {
358 Self {
359 id: self.id,
360 started_at: self.started_at,
361 ended_at: self.ended_at,
362 duration_seconds: self.duration_seconds,
363 manual: self.manual,
364 reason: self.reason.clone(),
365 }
366 }
367}
368
369#[derive(Debug, sqlx::FromRow)]
371struct TaskRow {
372 date: NaiveDate,
373 id: Uuid,
374 name: String,
375 comment: Option<String>,
376 completeness: i16,
377 recorded_at: DateTime<Utc>,
378}
379
380impl TaskRow {
381 fn split(self) -> (NaiveDate, Task) {
382 (
383 self.date,
384 Task {
385 id: self.id,
386 name: self.name,
387 comment: self.comment,
388 completeness: self.completeness,
389 recorded_at: self.recorded_at,
390 },
391 )
392 }
393}
394
395impl Task {
396 fn clone_row(&self) -> Self {
397 Self {
398 id: self.id,
399 name: self.name.clone(),
400 comment: self.comment.clone(),
401 completeness: self.completeness,
402 recorded_at: self.recorded_at,
403 }
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn range(from: &str, to: &str) -> Range {
412 Range {
413 from: from.parse().expect("a test date"),
414 to: to.parse().expect("a test date"),
415 }
416 }
417
418 #[test]
419 fn a_single_day_is_a_valid_range() {
420 assert!(validate_range(&range("2026-08-27", "2026-08-27")).is_ok());
424 }
425
426 #[test]
427 fn a_backwards_range_is_refused() {
428 let error = validate_range(&range("2026-08-27", "2026-08-01")).unwrap_err();
429 assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
430 assert!(error.to_string().contains("before"), "the message should say what is wrong: {error}");
431 }
432
433 #[test]
434 fn the_range_has_a_ceiling() {
435 let widest = range("2026-01-01", "2027-02-04");
438 assert_eq!((widest.to - widest.from).num_days() + 1, MAX_RANGE_DAYS);
439 assert!(validate_range(&widest).is_ok(), "exactly {MAX_RANGE_DAYS} days is allowed");
440
441 let too_wide = range("2026-01-01", "2027-02-05");
442 let error = validate_range(&too_wide).unwrap_err();
443 assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
444 assert!(error.to_string().contains("401"), "the message should name the span asked for: {error}");
445 }
446
447 #[test]
448 fn a_level_names_what_it_withholds() {
449 assert!(not_stored_at(PrivacyLevel::Full).is_empty());
453 assert_eq!(not_stored_at(PrivacyLevel::Moderate), vec!["free_text"]);
454 assert_eq!(not_stored_at(PrivacyLevel::Coarse), vec!["pauses", "tasks", "free_text"]);
455 }
456}