use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::{Datelike, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::{
admin::{VISIBLE_USERS, require_manager_or_admin},
app::AppState,
error::ApiError,
login::CurrentUser,
model::UserRole,
};
pub const TREND_WEEKS: i64 = 12;
const DECLINING_WEEKS: usize = 3;
const DECLINING_FRACTION: f64 = 0.15;
const UNUSUAL_FRACTION: f64 = 0.4;
const MIN_WEEKS_FOR_MEDIAN: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalKind {
Declining,
NoData,
UnusualWeek,
}
#[derive(Debug, Clone, Serialize)]
pub struct Signal {
pub user_id: Uuid,
pub display_name: String,
pub department: Option<String>,
pub kind: SignalKind,
pub weeks: Option<i64>,
pub from_seconds: Option<i64>,
pub to_seconds: Option<i64>,
pub median_seconds: Option<i64>,
pub days_quiet: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct Signals {
pub from: NaiveDate,
pub to: NaiveDate,
pub signals: Vec<Signal>,
pub people: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TrendWeek {
pub week_start: NaiveDate,
pub worked_seconds: i64,
pub days_recorded: i64,
}
#[derive(Debug, Serialize)]
pub struct Trend {
pub user_id: Uuid,
pub weeks: Vec<TrendWeek>,
pub median_seconds: Option<i64>,
pub signals: Vec<Signal>,
}
pub async fn team(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
let (from, to) = window(Utc::now().date_naive());
let rows = weekly_totals(&state.pool, &user, None, from, to).await?;
let people = rows.len() as i64;
let mut signals: Vec<Signal> = rows.iter().flat_map(|person| person.signals(to)).collect();
signals.sort_by_key(|signal| (severity(signal.kind), -signal.weeks.unwrap_or(0), -signal.days_quiet.unwrap_or(0)));
Ok(Json(Signals { from, to, signals, people }))
}
pub async fn user_trend(
State(state): State<AppState>,
user: CurrentUser,
Path(target): Path<Uuid>,
Query(_): Query<TrendQuery>,
) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
let (from, to) = window(Utc::now().date_naive());
let rows = weekly_totals(&state.pool, &user, Some(target), from, to).await?;
let Some(person) = rows.into_iter().next() else {
return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
};
let signals = person.signals(to);
let weeks = person.filled_weeks(from, to);
let median_seconds = person.median();
Ok(Json(Trend {
user_id: person.user_id,
weeks,
median_seconds,
signals,
}))
}
#[derive(Debug, Deserialize)]
pub struct TrendQuery {}
pub fn window(today: NaiveDate) -> (NaiveDate, NaiveDate) {
let this_monday = today - chrono::Duration::days(i64::from(today.weekday().num_days_from_monday()));
let last_sunday = this_monday - chrono::Duration::days(1);
let first_monday = this_monday - chrono::Duration::weeks(TREND_WEEKS);
(first_monday, last_sunday)
}
fn severity(kind: SignalKind) -> u8 {
match kind {
SignalKind::NoData => 0,
SignalKind::Declining => 1,
SignalKind::UnusualWeek => 2,
}
}
#[derive(Debug)]
struct Person {
user_id: Uuid,
display_name: String,
department: Option<String>,
weeks: Vec<TrendWeek>,
last_day: Option<NaiveDate>,
}
impl Person {
fn median(&self) -> Option<i64> {
let mut worked: Vec<i64> = self.weeks.iter().map(|week| week.worked_seconds).filter(|seconds| *seconds > 0).collect();
if worked.len() < MIN_WEEKS_FOR_MEDIAN {
return None;
}
worked.sort_unstable();
Some(median_of_sorted(&worked))
}
fn filled_weeks(&self, from: NaiveDate, to: NaiveDate) -> Vec<TrendWeek> {
let mut weeks = Vec::new();
let mut monday = from;
while monday <= to {
let recorded = self.weeks.iter().find(|week| week.week_start == monday);
weeks.push(TrendWeek {
week_start: monday,
worked_seconds: recorded.map_or(0, |week| week.worked_seconds),
days_recorded: recorded.map_or(0, |week| week.days_recorded),
});
monday += chrono::Duration::weeks(1);
}
weeks
}
fn signals(&self, to: NaiveDate) -> Vec<Signal> {
let mut found = Vec::new();
if let Some((weeks, from_seconds, to_seconds)) = self.declining() {
found.push(self.signal(SignalKind::Declining, |signal| {
signal.weeks = Some(weeks as i64);
signal.from_seconds = Some(from_seconds);
signal.to_seconds = Some(to_seconds);
}));
}
if let Some(days_quiet) = self.quiet_for(to) {
found.push(self.signal(SignalKind::NoData, |signal| signal.days_quiet = Some(days_quiet)));
}
if let Some((week_seconds, median)) = self.unusual_week(to) {
found.push(self.signal(SignalKind::UnusualWeek, |signal| {
signal.to_seconds = Some(week_seconds);
signal.median_seconds = Some(median);
}));
}
found
}
fn signal(&self, kind: SignalKind, fill: impl FnOnce(&mut Signal)) -> Signal {
let mut signal = Signal {
user_id: self.user_id,
display_name: self.display_name.clone(),
department: self.department.clone(),
kind,
weeks: None,
from_seconds: None,
to_seconds: None,
median_seconds: None,
days_quiet: None,
};
fill(&mut signal);
signal
}
fn declining(&self) -> Option<(usize, i64, i64)> {
let worked: Vec<i64> = self.weeks.iter().map(|week| week.worked_seconds).filter(|seconds| *seconds > 0).collect();
if worked.len() < DECLINING_WEEKS * 2 {
return None;
}
let recent = median_of(&worked[worked.len() - DECLINING_WEEKS..]);
let before = median_of(&worked[worked.len() - DECLINING_WEEKS * 2..worked.len() - DECLINING_WEEKS]);
if before <= 0 {
return None;
}
let drop = (before - recent) as f64 / before as f64;
(drop >= DECLINING_FRACTION).then_some((DECLINING_WEEKS, before, recent))
}
fn quiet_for(&self, to: NaiveDate) -> Option<i64> {
let last_day = self.last_day?;
let days = (to - last_day).num_days();
(days >= 10).then_some(days)
}
fn unusual_week(&self, to: NaiveDate) -> Option<(i64, i64)> {
let median = self.median()?;
let last_monday = to - chrono::Duration::days(6);
let last = self.weeks.iter().find(|week| week.week_start == last_monday)?;
if last.worked_seconds == 0 {
return None;
}
let deviation = (last.worked_seconds - median).abs() as f64 / median as f64;
(deviation >= UNUSUAL_FRACTION).then_some((last.worked_seconds, median))
}
}
fn median_of(values: &[i64]) -> i64 {
if values.is_empty() {
return 0;
}
let mut sorted = values.to_vec();
sorted.sort_unstable();
median_of_sorted(&sorted)
}
fn median_of_sorted(sorted: &[i64]) -> i64 {
let middle = sorted.len() / 2;
if sorted.len() % 2 == 1 {
sorted[middle]
} else {
(sorted[middle - 1] + sorted[middle]) / 2
}
}
#[derive(Debug, sqlx::FromRow)]
struct WeekRow {
user_id: Uuid,
display_name: String,
department: Option<String>,
week_start: Option<NaiveDate>,
worked_seconds: Option<i64>,
days_recorded: Option<i64>,
last_day: Option<NaiveDate>,
}
async fn weekly_totals(pool: &PgPool, reader: &CurrentUser, only: Option<Uuid>, from: NaiveDate, to: NaiveDate) -> Result<Vec<Person>, ApiError> {
let one_person = if only.is_some() { "AND u.id = $5" } else { "" };
let rows: Vec<WeekRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT u.id AS user_id, u.display_name, d.name AS department,
w.week_start, w.worked_seconds, w.days_recorded,
(SELECT max(all_days.date) FROM workdays all_days WHERE all_days.user_id = u.id) AS last_day
FROM users u
LEFT JOIN departments d ON d.id = u.department_id
LEFT JOIN LATERAL (
SELECT (date_trunc('week', wd.date))::date AS week_start,
count(*)::bigint AS days_recorded,
coalesce(sum(
CASE WHEN wd.ended_at IS NULL THEN 0
ELSE greatest(extract(epoch FROM (wd.ended_at - wd.started_at))::bigint - paused.seconds, 0)
END
), 0)::bigint AS worked_seconds
FROM workdays wd
CROSS JOIN LATERAL (
-- Stored pauses where they exist; the day's own totals where a
-- narrower policy summarized them away (ADR 0011). One or the
-- other, never both, so an hour cannot be counted twice.
SELECT CASE
WHEN EXISTS (SELECT 1 FROM pauses p WHERE p.workday_id = wd.id)
THEN (SELECT coalesce(sum(p.duration_seconds), 0)::bigint FROM pauses p WHERE p.workday_id = wd.id)
ELSE coalesce(wd.paused_seconds, 0)::bigint
END AS seconds
) AS paused
WHERE wd.user_id = u.id AND wd.date BETWEEN $3 AND $4
GROUP BY 1
) AS w ON true
WHERE u.active AND {VISIBLE_USERS} {one_person}
ORDER BY u.display_name, u.email, w.week_start"
)))
.bind(reader.role == UserRole::Admin)
.bind(reader.user_id)
.bind(from)
.bind(to)
.bind(only.unwrap_or_else(Uuid::nil))
.fetch_all(pool)
.await?;
Ok(into_people(rows))
}
fn into_people(rows: Vec<WeekRow>) -> Vec<Person> {
let mut people: Vec<Person> = Vec::new();
for row in rows {
if people.last().map(|person| person.user_id) != Some(row.user_id) {
people.push(Person {
user_id: row.user_id,
display_name: row.display_name,
department: row.department,
weeks: Vec::new(),
last_day: row.last_day,
});
}
let person = people.last_mut().expect("a person was just pushed");
let Some(week_start) = row.week_start else { continue };
person.weeks.push(TrendWeek {
week_start,
worked_seconds: row.worked_seconds.unwrap_or(0),
days_recorded: row.days_recorded.unwrap_or(0),
});
}
people
}
#[cfg(test)]
mod tests {
use super::*;
fn date(text: &str) -> NaiveDate {
text.parse().expect("a test date")
}
const LAST_MONDAY: &str = "2026-08-24";
fn person_with(hours: &[f64]) -> Person {
build(hours, false)
}
fn person_with_open(hours: &[f64]) -> Person {
build(hours, true)
}
fn build(hours: &[f64], keep_zero_weeks: bool) -> Person {
let last = date(LAST_MONDAY);
let count = hours.len();
let weeks: Vec<TrendWeek> = hours
.iter()
.enumerate()
.map(|(index, hours)| TrendWeek {
week_start: last - chrono::Duration::weeks((count - 1 - index) as i64),
worked_seconds: (hours * 3600.0) as i64,
days_recorded: if *hours > 0.0 || keep_zero_weeks { 5 } else { 0 },
})
.filter(|week| week.days_recorded > 0)
.collect();
Person {
user_id: Uuid::new_v4(),
display_name: "Test".into(),
department: None,
weeks,
last_day: Some(last + chrono::Duration::days(4)),
}
}
fn window_end() -> NaiveDate {
date(LAST_MONDAY) + chrono::Duration::days(6)
}
#[test]
fn the_window_ends_at_the_last_complete_week() {
let (from, to) = window(date("2026-09-02"));
assert_eq!(to, date("2026-08-30"), "the Sunday before this week");
assert_eq!(from, date("2026-06-08"), "twelve complete weeks back");
assert_eq!((to - from).num_days() + 1, TREND_WEEKS * 7);
}
#[test]
fn a_monday_does_not_count_its_own_week() {
let (_, to) = window(date("2026-08-31"));
assert_eq!(to, date("2026-08-30"));
}
#[test]
fn a_sunday_belongs_to_the_week_that_just_ended() {
let (_, to) = window(date("2026-08-30"));
assert_eq!(to, date("2026-08-23"), "the week containing Sunday is still the current one");
}
#[test]
fn the_median_is_the_middle_not_the_mean() {
assert_eq!(median_of_sorted(&[1, 2, 3, 4, 40]), 3);
assert_eq!(median_of_sorted(&[2, 4]), 3);
assert_eq!(median_of_sorted(&[7]), 7);
}
#[test]
fn a_decline_compares_levels_rather_than_counting_steps() {
let slid = person_with(&[40.0, 40.0, 40.0, 30.0, 30.0, 30.0]);
let (weeks, before, now) = slid.declining().expect("a quarter off the level is a decline");
assert_eq!(weeks, DECLINING_WEEKS);
assert_eq!(before, 40 * 3600);
assert_eq!(now, 30 * 3600);
}
#[test]
fn an_uneven_slide_is_still_a_slide() {
let lukas = person_with(&[35.0, 33.0, 24.8, 27.0, 20.9, 23.1, 22.1]);
let (_, before, now) = lukas.declining().expect("an uneven slide must not go unreported");
assert!(before > now, "the direction has to survive the medians: {before} -> {now}");
}
#[test]
fn ordinary_variation_is_not_a_decline() {
let wobbly = person_with(&[40.0, 37.0, 41.0, 39.0, 38.0, 40.0]);
assert_eq!(wobbly.declining(), None);
}
#[test]
fn a_recovery_is_not_reported_as_a_decline() {
let recovered = person_with(&[40.0, 30.0, 30.0, 40.0, 40.0, 41.0]);
assert_eq!(recovered.declining(), None);
}
#[test]
fn too_little_history_cannot_show_a_decline() {
let short = person_with(&[40.0, 40.0, 40.0, 20.0, 20.0]);
assert_eq!(short.declining(), None);
}
#[test]
fn a_missing_week_is_a_gap_not_a_crash() {
let holiday = person_with(&[40.0, 40.0, 40.0, 0.0, 0.0, 40.0, 40.0, 40.0]);
assert_eq!(holiday.declining(), None, "a gap is missing data, not falling hours");
}
#[test]
fn a_week_of_days_still_open_is_not_a_week_of_no_work() {
let filing_late = person_with_open(&[40.0, 40.0, 40.0, 40.0, 0.0]);
assert_eq!(filing_late.declining(), None, "an unfiled week is not a decline");
let interrupted = person_with_open(&[40.0, 40.0, 40.0, 39.0, 0.0, 41.0]);
assert_eq!(interrupted.declining(), None, "an unfiled week must not be read as a week of no work");
assert_eq!(filing_late.median(), Some(40 * 3600));
}
#[test]
fn silence_is_measured_from_the_last_real_day() {
let mut quiet = person_with(&[40.0, 40.0, 40.0, 40.0]);
quiet.last_day = Some(window_end() - chrono::Duration::days(14));
assert_eq!(quiet.quiet_for(window_end()), Some(14));
let working = person_with(&[40.0, 40.0, 40.0, 40.0]);
assert_eq!(working.quiet_for(window_end()), None);
}
#[test]
fn someone_who_never_reported_gets_no_silence_signal() {
let mut never = person_with(&[]);
never.last_day = None;
assert_eq!(never.quiet_for(window_end()), None);
}
#[test]
fn an_unusual_week_is_unusual_in_either_direction() {
let low = person_with(&[40.0, 40.0, 40.0, 40.0, 20.0]);
let (week, median) = low.unusual_week(window_end()).expect("half the usual week is unusual");
assert_eq!(week, 20 * 3600);
assert_eq!(median, 40 * 3600);
let high = person_with(&[40.0, 40.0, 40.0, 40.0, 60.0]);
assert!(high.unusual_week(window_end()).is_some(), "a week half again as long is unusual too");
let ordinary = person_with(&[40.0, 40.0, 40.0, 40.0, 32.0]);
assert_eq!(ordinary.unusual_week(window_end()), None);
}
#[test]
fn too_little_history_means_no_median_and_no_signal() {
let new_hire = person_with(&[40.0, 40.0, 10.0]);
assert_eq!(new_hire.median(), None);
assert_eq!(new_hire.unusual_week(window_end()), None, "no median, no comparison");
}
#[test]
fn an_empty_last_week_is_reported_as_silence_only_once() {
let mut stopped = person_with(&[40.0, 40.0, 40.0, 40.0, 0.0]);
stopped.last_day = Some(window_end() - chrono::Duration::days(11));
assert_eq!(stopped.unusual_week(window_end()), None);
let kinds: Vec<SignalKind> = stopped.signals(window_end()).into_iter().map(|signal| signal.kind).collect();
assert_eq!(kinds, vec![SignalKind::NoData], "one fact, one signal");
}
#[test]
fn last_week_means_the_last_week_of_the_window() {
let stopped = person_with(&[40.0, 40.0, 40.0, 40.0, 7.0, 0.0]);
assert_eq!(
stopped.weeks.last().map(|week| week.worked_seconds),
Some(7 * 3600),
"the fixture must leave the short week as the newest one on record, or this test proves nothing"
);
assert!(
stopped.weeks.iter().all(|week| week.week_start < date(LAST_MONDAY)),
"and the window's own last week must be missing from the data"
);
assert_eq!(
stopped.unusual_week(window_end()),
None,
"a week that is not last week must not be reported as last week"
);
}
#[test]
fn a_steady_person_produces_nothing() {
let steady = person_with(&[40.0, 39.0, 41.0, 40.0, 40.5]);
assert!(steady.signals(window_end()).is_empty(), "a steady person is not news");
}
#[test]
fn the_fading_person_from_the_demo_is_caught() {
let fading = person_with(&[42.5, 40.0, 37.5, 32.5, 30.0, 25.0]);
let signals = fading.signals(window_end());
let declining = signals
.iter()
.find(|signal| signal.kind == SignalKind::Declining)
.expect("the fading person should be flagged");
assert!(declining.weeks.unwrap_or(0) >= 3, "{declining:?}");
assert!(
declining.from_seconds > declining.to_seconds,
"the figures must show the direction: {declining:?}"
);
}
#[test]
fn a_trend_keeps_its_empty_weeks() {
let person = person_with(&[40.0, 0.0, 40.0]);
let weeks = person.filled_weeks(date(LAST_MONDAY) - chrono::Duration::weeks(2), date(LAST_MONDAY));
assert_eq!(weeks.len(), 3, "every week in the window, not only the worked ones");
assert_eq!(weeks[1].worked_seconds, 0);
assert_eq!(weeks[1].days_recorded, 0, "zero days says the silence is real");
}
#[test]
fn signals_are_ordered_worst_first() {
let mut kinds = [SignalKind::UnusualWeek, SignalKind::Declining, SignalKind::NoData];
kinds.sort_by_key(|kind| severity(*kind));
assert_eq!(kinds, [SignalKind::NoData, SignalKind::Declining, SignalKind::UnusualWeek]);
}
}