Skip to main content

kasl_server/
signals.rs

1//! What the dashboard should point at: `GET /api/v1/team/signals` and the
2//! weekly trend behind `GET /api/v1/users/{id}/trend`.
3//!
4//! Every screen before this one answers a question the manager asked. This one
5//! answers the question they did not know to ask - somebody's hours have been
6//! sliding for three weeks, and no single view shows it, because the pattern
7//! only exists across weeks and nobody scrolls back through weeks looking.
8//!
9//! The rules are in ADR 0016, and two of them shape every line here:
10//!
11//! * **A person is compared with themselves.** Never with a colleague, never
12//!   with a norm - this server has none until the production calendar (v0.21),
13//!   and a threshold invented before then would be this product asserting what
14//!   a working day should be on somebody else's team.
15//! * **A signal is a question, not a verdict.** Each one carries the figures it
16//!   came from, so the screen can say "8.5 h → 5.0 h over three weeks" instead
17//!   of a badge reading "problem". Falling hours are a holiday, a hospital, or
18//!   a project that ended, and the server knows none of that.
19//!
20//! The arithmetic deliberately lives in Rust over weekly sums, not in SQL: the
21//! statistics are the part most worth testing, and logic inside a query can
22//! only be tested through a database.
23
24use axum::{
25    Json,
26    extract::{Path, Query, State},
27    http::StatusCode,
28    response::IntoResponse,
29};
30use chrono::{Datelike, NaiveDate, Utc};
31use serde::{Deserialize, Serialize};
32use sqlx::PgPool;
33use uuid::Uuid;
34
35use crate::{
36    admin::{VISIBLE_USERS, require_manager_or_admin},
37    app::AppState,
38    error::ApiError,
39    login::CurrentUser,
40    model::UserRole,
41};
42
43/// How many complete weeks the trend and the signals look back over.
44///
45/// A quarter: long enough that a three-week slide has weeks behind it to be a
46/// slide *from*, short enough that a person who changed roles in the spring is
47/// not still being measured against who they were then.
48pub const TREND_WEEKS: i64 = 12;
49
50/// How many weeks each side of the comparison a decline is measured over.
51///
52/// Three, not two. Two weeks is one bad week next to one ordinary one, and a
53/// dashboard that fired on that would flag everybody who took a Friday off -
54/// which teaches people to ignore the column (the lesson `unknown` taught in
55/// ADR 0014). Six weeks of history are needed before the question can be
56/// asked at all.
57const DECLINING_WEEKS: usize = 3;
58
59/// How far the recent level must sit below the earlier one to be a decline,
60/// as a fraction of the earlier level.
61///
62/// Fifteen per cent: a slide worth a manager's attention, and above the noise
63/// of one short week inside a three-week median. Taken from the demo's fading
64/// person, whose real shape comes to about nineteen per cent - the threshold
65/// has to catch that without firing on ordinary variation.
66const DECLINING_FRACTION: f64 = 0.15;
67
68/// How far from a person's own median a week must fall to be called unusual,
69/// as a fraction of that median.
70///
71/// Forty per cent in either direction: a four-day week is about twenty per
72/// cent down and is nobody's business, while half a week or half again is
73/// something a manager would want to have noticed themselves.
74const UNUSUAL_FRACTION: f64 = 0.4;
75
76/// The fewest weeks with any hours before a person's median means anything.
77///
78/// Below this there is no "usual for them" to compare against, and a signal
79/// derived from two weeks of history would be an opinion about a new hire.
80const MIN_WEEKS_FOR_MEDIAN: usize = 4;
81
82/// What kind of thing the server noticed.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
84#[serde(rename_all = "snake_case")]
85pub enum SignalKind {
86    /// The recent weeks sit well below the weeks before them.
87    Declining,
88    /// Nothing recorded for longer than this person's own usual gap.
89    NoData,
90    /// The last complete week is far from this person's own median, either way.
91    UnusualWeek,
92}
93
94/// One thing worth looking at, about one person.
95///
96/// The figures travel with the signal so the screen states what happened
97/// rather than what it means: the server saw hours fall from 8.5 to 5.0, and
98/// what that is about is between the manager and the person.
99#[derive(Debug, Clone, Serialize)]
100pub struct Signal {
101    pub user_id: Uuid,
102    pub display_name: String,
103    pub department: Option<String>,
104    pub kind: SignalKind,
105    /// Weeks on each side of the comparison, for `declining`.
106    pub weeks: Option<i64>,
107    /// The earlier level, in seconds of a typical week - the median of the
108    /// weeks before the recent ones. `declining` only.
109    pub from_seconds: Option<i64>,
110    /// The recent level, on the same terms. `declining` and `unusual_week`.
111    pub to_seconds: Option<i64>,
112    /// This person's own median week, in seconds. `unusual_week` only - it is
113    /// the thing being compared against, and a screen that showed the deviation
114    /// without it would be quoting a percentage of nothing.
115    pub median_seconds: Option<i64>,
116    /// Days since the last recorded day. `no_data` only.
117    pub days_quiet: Option<i64>,
118}
119
120/// The team's signals, most worth looking at first.
121#[derive(Debug, Serialize)]
122pub struct Signals {
123    /// The window the signals were computed over.
124    pub from: NaiveDate,
125    /// The last day of the last **complete** week. The current week is never
126    /// included: a Tuesday is not a short week, but it looks like one to
127    /// arithmetic, and `declining` would fire on the whole team every Monday.
128    pub to: NaiveDate,
129    pub signals: Vec<Signal>,
130    /// People examined. `0 of 12` is a different message from "nothing wrong",
131    /// and a screen that cannot tell them apart says the reassuring one.
132    pub people: i64,
133}
134
135/// One person's week on the trend chart.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137pub struct TrendWeek {
138    /// The Monday the week starts on.
139    pub week_start: NaiveDate,
140    pub worked_seconds: i64,
141    /// Days with a workday in that week. Zero says the silence is real rather
142    /// than a week of very short days.
143    pub days_recorded: i64,
144}
145
146/// The weekly trend for one person.
147#[derive(Debug, Serialize)]
148pub struct Trend {
149    pub user_id: Uuid,
150    /// Every complete week in the window, including the empty ones - a gap
151    /// drawn as a gap is the point of the chart, and dropping empty weeks
152    /// would close it up and hide the absence.
153    pub weeks: Vec<TrendWeek>,
154    /// This person's median week over the window, in seconds; `null` when
155    /// there is too little history for a median to mean anything.
156    pub median_seconds: Option<i64>,
157    /// What the server noticed about this person, if anything.
158    pub signals: Vec<Signal>,
159}
160
161/// Answers the signals for everyone the reader may see.
162pub async fn team(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
163    require_manager_or_admin(&user)?;
164
165    let (from, to) = window(Utc::now().date_naive());
166    let rows = weekly_totals(&state.pool, &user, None, from, to).await?;
167    let people = rows.len() as i64;
168
169    let mut signals: Vec<Signal> = rows.iter().flat_map(|person| person.signals(to)).collect();
170    // Worst first: a list a manager reads top-down should start with silence,
171    // then the declines, rather than with whoever sorts first by name.
172    signals.sort_by_key(|signal| (severity(signal.kind), -signal.weeks.unwrap_or(0), -signal.days_quiet.unwrap_or(0)));
173
174    Ok(Json(Signals { from, to, signals, people }))
175}
176
177/// Answers one person's weekly trend, to someone allowed to see them.
178pub async fn user_trend(
179    State(state): State<AppState>,
180    user: CurrentUser,
181    Path(target): Path<Uuid>,
182    Query(_): Query<TrendQuery>,
183) -> Result<impl IntoResponse, ApiError> {
184    require_manager_or_admin(&user)?;
185
186    let (from, to) = window(Utc::now().date_naive());
187    let rows = weekly_totals(&state.pool, &user, Some(target), from, to).await?;
188
189    // Not "no such user": a manager probing ids must not be able to tell an
190    // employee in another department from one who does not exist - the same
191    // rule the drill-down follows in `team`.
192    let Some(person) = rows.into_iter().next() else {
193        return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
194    };
195
196    let signals = person.signals(to);
197    let weeks = person.filled_weeks(from, to);
198    let median_seconds = person.median();
199
200    Ok(Json(Trend {
201        user_id: person.user_id,
202        weeks,
203        median_seconds,
204        signals,
205    }))
206}
207
208/// Nothing yet - the window is fixed at [`TREND_WEEKS`]. Present so the route
209/// can grow one without becoming a different path.
210#[derive(Debug, Deserialize)]
211pub struct TrendQuery {}
212
213/// The window the signals and the trend are computed over: complete weeks only.
214///
215/// Ends on the Sunday before the current week, so a partial week never enters
216/// the arithmetic. Separate from the handlers so "which weeks count" can be
217/// tested against a fixed date rather than against whatever day CI runs on.
218pub fn window(today: NaiveDate) -> (NaiveDate, NaiveDate) {
219    let this_monday = today - chrono::Duration::days(i64::from(today.weekday().num_days_from_monday()));
220    let last_sunday = this_monday - chrono::Duration::days(1);
221    let first_monday = this_monday - chrono::Duration::weeks(TREND_WEEKS);
222    (first_monday, last_sunday)
223}
224
225/// Which signals a screen shows first. Lower sorts earlier.
226fn severity(kind: SignalKind) -> u8 {
227    match kind {
228        // Silence outranks a slide: an agent that stopped reporting means the
229        // other numbers about that person are not to be trusted either.
230        SignalKind::NoData => 0,
231        SignalKind::Declining => 1,
232        SignalKind::UnusualWeek => 2,
233    }
234}
235
236/// One person with their weekly sums, as the database grouped them.
237#[derive(Debug)]
238struct Person {
239    user_id: Uuid,
240    display_name: String,
241    department: Option<String>,
242    /// Only the weeks with days in them, ascending. Absent weeks are absent
243    /// data, filled in by [`Person::filled_weeks`] where a chart needs them.
244    weeks: Vec<TrendWeek>,
245    /// The last date with a workday, at any time - not only inside the window,
246    /// so "quiet since June" is measured from June rather than from the edge of
247    /// the chart.
248    last_day: Option<NaiveDate>,
249}
250
251impl Person {
252    /// This person's median week, over the weeks they actually worked.
253    ///
254    /// The median rather than the mean: one crunch week drags a mean far
255    /// enough to hide a real decline behind it, and one week off drags it the
256    /// other way. `None` when there is too little history for "usual for them"
257    /// to mean anything.
258    fn median(&self) -> Option<i64> {
259        let mut worked: Vec<i64> = self.weeks.iter().map(|week| week.worked_seconds).filter(|seconds| *seconds > 0).collect();
260        if worked.len() < MIN_WEEKS_FOR_MEDIAN {
261            return None;
262        }
263        worked.sort_unstable();
264        Some(median_of_sorted(&worked))
265    }
266
267    /// Every week in the window, including the ones with nothing in them.
268    fn filled_weeks(&self, from: NaiveDate, to: NaiveDate) -> Vec<TrendWeek> {
269        let mut weeks = Vec::new();
270        let mut monday = from;
271        while monday <= to {
272            let recorded = self.weeks.iter().find(|week| week.week_start == monday);
273            weeks.push(TrendWeek {
274                week_start: monday,
275                worked_seconds: recorded.map_or(0, |week| week.worked_seconds),
276                days_recorded: recorded.map_or(0, |week| week.days_recorded),
277            });
278            monday += chrono::Duration::weeks(1);
279        }
280        weeks
281    }
282
283    /// What the server noticed about this person.
284    fn signals(&self, to: NaiveDate) -> Vec<Signal> {
285        let mut found = Vec::new();
286
287        if let Some((weeks, from_seconds, to_seconds)) = self.declining() {
288            found.push(self.signal(SignalKind::Declining, |signal| {
289                signal.weeks = Some(weeks as i64);
290                signal.from_seconds = Some(from_seconds);
291                signal.to_seconds = Some(to_seconds);
292            }));
293        }
294
295        if let Some(days_quiet) = self.quiet_for(to) {
296            found.push(self.signal(SignalKind::NoData, |signal| signal.days_quiet = Some(days_quiet)));
297        }
298
299        if let Some((week_seconds, median)) = self.unusual_week(to) {
300            found.push(self.signal(SignalKind::UnusualWeek, |signal| {
301                signal.to_seconds = Some(week_seconds);
302                signal.median_seconds = Some(median);
303            }));
304        }
305
306        found
307    }
308
309    /// A signal about this person, with the figures filled in by the caller.
310    fn signal(&self, kind: SignalKind, fill: impl FnOnce(&mut Signal)) -> Signal {
311        let mut signal = Signal {
312            user_id: self.user_id,
313            display_name: self.display_name.clone(),
314            department: self.department.clone(),
315            kind,
316            weeks: None,
317            from_seconds: None,
318            to_seconds: None,
319            median_seconds: None,
320            days_quiet: None,
321        };
322        fill(&mut signal);
323        signal
324    }
325
326    /// Whether the recent level of work sits well below the level before it,
327    /// and between which figures - as `(weeks compared, before, now)`.
328    ///
329    /// **Levels, not steps.** An earlier version asked for three weeks each
330    /// lower than the last, and a live run against the demo showed why that is
331    /// the wrong question: a genuinely fading person went 33 → 24.8 → 27 →
332    /// 20.9 → 23.1 → 22.1, which is an unmistakable slide and never three
333    /// falls in a row. One ordinary week in the middle resets a run, so the
334    /// strict version stays silent on exactly the case the milestone exists
335    /// for. Comparing the median of the last three weeks with the median of
336    /// the three before them sees the same data as a nineteen per cent drop.
337    ///
338    /// Medians on both sides for the usual reason: one crunch week either side
339    /// would otherwise decide the answer on its own.
340    ///
341    /// Computed over the weeks that actually have hours. A week the database
342    /// never grouped is simply absent; the zero that does arrive is a week
343    /// whose days were all still open, and skipping it keeps a slide visible
344    /// *through* it rather than letting a late-filed week look like a crash.
345    fn declining(&self) -> Option<(usize, i64, i64)> {
346        let worked: Vec<i64> = self.weeks.iter().map(|week| week.worked_seconds).filter(|seconds| *seconds > 0).collect();
347
348        // Two windows' worth of weeks, or there is no "before" to fall from.
349        if worked.len() < DECLINING_WEEKS * 2 {
350            return None;
351        }
352
353        let recent = median_of(&worked[worked.len() - DECLINING_WEEKS..]);
354        let before = median_of(&worked[worked.len() - DECLINING_WEEKS * 2..worked.len() - DECLINING_WEEKS]);
355
356        if before <= 0 {
357            return None;
358        }
359
360        let drop = (before - recent) as f64 / before as f64;
361        (drop >= DECLINING_FRACTION).then_some((DECLINING_WEEKS, before, recent))
362    }
363
364    /// Days since the last recorded day, when that is longer than this person's
365    /// own rhythm allows.
366    ///
367    /// Their own rhythm, not a fixed number: somebody who reports every day and
368    /// somebody who files a week at a time are both normal, and one threshold
369    /// for the two would either miss the first or nag the second.
370    fn quiet_for(&self, to: NaiveDate) -> Option<i64> {
371        // Never reported at all is a different fact, and the dashboard already
372        // has better words for it - "never reported", next to an agent count.
373        let last_day = self.last_day?;
374        let days = (to - last_day).num_days();
375
376        // The window closed on a Sunday, so a person who worked to the end of
377        // the last week is already one or two days "quiet". Ten days means a
378        // week and a half with nothing at all, which no ordinary rhythm covers.
379        (days >= 10).then_some(days)
380    }
381
382    /// The last complete week, when it is far from this person's own median.
383    fn unusual_week(&self, to: NaiveDate) -> Option<(i64, i64)> {
384        let median = self.median()?;
385
386        // The last week of the *window*, not the last week with data in it.
387        // `weeks` holds only the weeks somebody worked, so its final entry can
388        // be a fortnight old - and calling that "last week" would describe a
389        // week nobody is thinking about. A live run found this: somebody who
390        // stopped reporting was told their last week was short, when the week
391        // in question had ended thirteen days earlier.
392        let last_monday = to - chrono::Duration::days(6);
393        let last = self.weeks.iter().find(|week| week.week_start == last_monday)?;
394
395        // A week with nothing in it is silence, and `no_data` is the signal
396        // for that. Calling it "unusual" too would report one fact twice.
397        if last.worked_seconds == 0 {
398            return None;
399        }
400
401        let deviation = (last.worked_seconds - median).abs() as f64 / median as f64;
402        (deviation >= UNUSUAL_FRACTION).then_some((last.worked_seconds, median))
403    }
404}
405
406/// The median of a slice in any order. Empty answers zero, which every caller
407/// guards against before it can mean anything.
408fn median_of(values: &[i64]) -> i64 {
409    if values.is_empty() {
410        return 0;
411    }
412    let mut sorted = values.to_vec();
413    sorted.sort_unstable();
414    median_of_sorted(&sorted)
415}
416
417/// The middle of a sorted, non-empty slice; the mean of the two middles when
418/// the count is even.
419fn median_of_sorted(sorted: &[i64]) -> i64 {
420    let middle = sorted.len() / 2;
421    if sorted.len() % 2 == 1 {
422        sorted[middle]
423    } else {
424        (sorted[middle - 1] + sorted[middle]) / 2
425    }
426}
427
428/// A row of the query: one person's one week.
429#[derive(Debug, sqlx::FromRow)]
430struct WeekRow {
431    user_id: Uuid,
432    display_name: String,
433    department: Option<String>,
434    week_start: Option<NaiveDate>,
435    worked_seconds: Option<i64>,
436    days_recorded: Option<i64>,
437    last_day: Option<NaiveDate>,
438}
439
440/// Loads weekly sums for everyone the reader may see, or for one person.
441///
442/// Grouped by week in the database and reasoned about in Rust: the grouping is
443/// what a database is for, and the statistics are what a test can only reach
444/// outside one.
445async fn weekly_totals(pool: &PgPool, reader: &CurrentUser, only: Option<Uuid>, from: NaiveDate, to: NaiveDate) -> Result<Vec<Person>, ApiError> {
446    // `AssertSqlSafe` because the only interpolation is `VISIBLE_USERS`, a
447    // constant in `admin`, and a fixed clause; every value is bound below.
448    let one_person = if only.is_some() { "AND u.id = $5" } else { "" };
449
450    let rows: Vec<WeekRow> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
451        "SELECT u.id AS user_id, u.display_name, d.name AS department,
452                w.week_start, w.worked_seconds, w.days_recorded,
453                (SELECT max(all_days.date) FROM workdays all_days WHERE all_days.user_id = u.id) AS last_day
454         FROM users u
455         LEFT JOIN departments d ON d.id = u.department_id
456         LEFT JOIN LATERAL (
457             SELECT (date_trunc('week', wd.date))::date AS week_start,
458                    count(*)::bigint AS days_recorded,
459                    coalesce(sum(
460                        CASE WHEN wd.ended_at IS NULL THEN 0
461                             ELSE greatest(extract(epoch FROM (wd.ended_at - wd.started_at))::bigint - paused.seconds, 0)
462                        END
463                    ), 0)::bigint AS worked_seconds
464             FROM workdays wd
465             CROSS JOIN LATERAL (
466                 -- Stored pauses where they exist; the day's own totals where a
467                 -- narrower policy summarized them away (ADR 0011). One or the
468                 -- other, never both, so an hour cannot be counted twice.
469                 SELECT CASE
470                     WHEN EXISTS (SELECT 1 FROM pauses p WHERE p.workday_id = wd.id)
471                     THEN (SELECT coalesce(sum(p.duration_seconds), 0)::bigint FROM pauses p WHERE p.workday_id = wd.id)
472                     ELSE coalesce(wd.paused_seconds, 0)::bigint
473                 END AS seconds
474             ) AS paused
475             WHERE wd.user_id = u.id AND wd.date BETWEEN $3 AND $4
476             GROUP BY 1
477         ) AS w ON true
478         WHERE u.active AND {VISIBLE_USERS} {one_person}
479         ORDER BY u.display_name, u.email, w.week_start"
480    )))
481    .bind(reader.role == UserRole::Admin)
482    .bind(reader.user_id)
483    .bind(from)
484    .bind(to)
485    // Bound unconditionally: sqlx counts placeholders in the string it was
486    // given, and an unused bind is cheaper than two nearly identical queries.
487    .bind(only.unwrap_or_else(Uuid::nil))
488    .fetch_all(pool)
489    .await?;
490
491    Ok(into_people(rows))
492}
493
494/// Folds the flat rows into one entry per person.
495///
496/// Relies on the query's `ORDER BY` keeping each person's weeks together, so
497/// this is a single pass and the order the database chose is the order the
498/// screen draws.
499fn into_people(rows: Vec<WeekRow>) -> Vec<Person> {
500    let mut people: Vec<Person> = Vec::new();
501
502    for row in rows {
503        if people.last().map(|person| person.user_id) != Some(row.user_id) {
504            people.push(Person {
505                user_id: row.user_id,
506                display_name: row.display_name,
507                department: row.department,
508                weeks: Vec::new(),
509                last_day: row.last_day,
510            });
511        }
512
513        let person = people.last_mut().expect("a person was just pushed");
514
515        // No week means the outer join found nothing: the person is the answer,
516        // the week is not.
517        let Some(week_start) = row.week_start else { continue };
518
519        person.weeks.push(TrendWeek {
520            week_start,
521            worked_seconds: row.worked_seconds.unwrap_or(0),
522            days_recorded: row.days_recorded.unwrap_or(0),
523        });
524    }
525
526    people
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn date(text: &str) -> NaiveDate {
534        text.parse().expect("a test date")
535    }
536
537    /// The Monday twelve-week window ending 2026-08-30, as the tests use it.
538    const LAST_MONDAY: &str = "2026-08-24";
539
540    /// A person whose weeks carry the given hours, oldest first, the last of
541    /// them being the week starting [`LAST_MONDAY`].
542    ///
543    /// A zero is a week nobody recorded, which the query answers by not
544    /// answering it at all - so it is dropped here too, exactly as the database
545    /// would drop it. For the *other* kind of zero see [`person_with_open`].
546    fn person_with(hours: &[f64]) -> Person {
547        build(hours, false)
548    }
549
550    /// The same, but a zero is a week whose days were all still open.
551    ///
552    /// This one the database really does emit: `GROUP BY` counts the days, and
553    /// an unfinished day contributes no seconds, so the row arrives with
554    /// `days_recorded > 0` and `worked_seconds = 0`. It is the only way a zero
555    /// reaches the statistics, and the case where reading it as "worked
556    /// nothing" would invent a crash out of a week that is simply not over.
557    fn person_with_open(hours: &[f64]) -> Person {
558        build(hours, true)
559    }
560
561    fn build(hours: &[f64], keep_zero_weeks: bool) -> Person {
562        let last = date(LAST_MONDAY);
563        let count = hours.len();
564        let weeks: Vec<TrendWeek> = hours
565            .iter()
566            .enumerate()
567            .map(|(index, hours)| TrendWeek {
568                week_start: last - chrono::Duration::weeks((count - 1 - index) as i64),
569                worked_seconds: (hours * 3600.0) as i64,
570                days_recorded: if *hours > 0.0 || keep_zero_weeks { 5 } else { 0 },
571            })
572            .filter(|week| week.days_recorded > 0)
573            .collect();
574
575        Person {
576            user_id: Uuid::new_v4(),
577            display_name: "Test".into(),
578            department: None,
579            weeks,
580            // Friday of the last week: someone who worked right up to the end
581            // of the window, so `no_data` stays quiet unless a test says so.
582            last_day: Some(last + chrono::Duration::days(4)),
583        }
584    }
585
586    /// The Sunday the window closes on, for the tests that need it.
587    fn window_end() -> NaiveDate {
588        date(LAST_MONDAY) + chrono::Duration::days(6)
589    }
590
591    #[test]
592    fn the_window_ends_at_the_last_complete_week() {
593        // A Wednesday: the window must stop at the Sunday before this Monday,
594        // never reaching into the half-lived current week.
595        let (from, to) = window(date("2026-09-02"));
596        assert_eq!(to, date("2026-08-30"), "the Sunday before this week");
597        assert_eq!(from, date("2026-06-08"), "twelve complete weeks back");
598        assert_eq!((to - from).num_days() + 1, TREND_WEEKS * 7);
599    }
600
601    #[test]
602    fn a_monday_does_not_count_its_own_week() {
603        // The edge that would otherwise fire `declining` across the team every
604        // Monday morning: today's week has one day in it and is not a week.
605        let (_, to) = window(date("2026-08-31"));
606        assert_eq!(to, date("2026-08-30"));
607    }
608
609    #[test]
610    fn a_sunday_belongs_to_the_week_that_just_ended() {
611        // Sunday is the last day of its own week, so that week is complete.
612        let (_, to) = window(date("2026-08-30"));
613        assert_eq!(to, date("2026-08-23"), "the week containing Sunday is still the current one");
614    }
615
616    #[test]
617    fn the_median_is_the_middle_not_the_mean() {
618        // One crunch week is exactly what would hide a decline behind a mean.
619        assert_eq!(median_of_sorted(&[1, 2, 3, 4, 40]), 3);
620        assert_eq!(median_of_sorted(&[2, 4]), 3);
621        assert_eq!(median_of_sorted(&[7]), 7);
622    }
623
624    #[test]
625    fn a_decline_compares_levels_rather_than_counting_steps() {
626        // Six weeks: a settled level, then a clearly lower one. The figures
627        // reported are the two medians, so the screen can say "40 h a week
628        // down to 30 h" without quoting a week nobody worked.
629        let slid = person_with(&[40.0, 40.0, 40.0, 30.0, 30.0, 30.0]);
630        let (weeks, before, now) = slid.declining().expect("a quarter off the level is a decline");
631        assert_eq!(weeks, DECLINING_WEEKS);
632        assert_eq!(before, 40 * 3600);
633        assert_eq!(now, 30 * 3600);
634    }
635
636    #[test]
637    fn an_uneven_slide_is_still_a_slide() {
638        // The shape that made this a levels comparison in the first place: the
639        // demo's fading person, as the seed really produces him. Never three
640        // falls in a row - one ordinary week in the middle resets a run - and
641        // unmistakably a decline to anyone looking at the chart.
642        let lukas = person_with(&[35.0, 33.0, 24.8, 27.0, 20.9, 23.1, 22.1]);
643        let (_, before, now) = lukas.declining().expect("an uneven slide must not go unreported");
644        assert!(before > now, "the direction has to survive the medians: {before} -> {now}");
645    }
646
647    #[test]
648    fn ordinary_variation_is_not_a_decline() {
649        // Weeks wobble. A signal that fired on this would fire on everybody,
650        // and a list everybody is on is not read.
651        let wobbly = person_with(&[40.0, 37.0, 41.0, 39.0, 38.0, 40.0]);
652        assert_eq!(wobbly.declining(), None);
653    }
654
655    #[test]
656    fn a_recovery_is_not_reported_as_a_decline() {
657        // Down and then back to the old level: nothing to point at now, which
658        // is the question this screen answers.
659        let recovered = person_with(&[40.0, 30.0, 30.0, 40.0, 40.0, 41.0]);
660        assert_eq!(recovered.declining(), None);
661    }
662
663    #[test]
664    fn too_little_history_cannot_show_a_decline() {
665        // Five weeks is not two three-week windows, so there is no "before" to
666        // have fallen from. Answering anything here would be an opinion about
667        // somebody who just arrived.
668        let short = person_with(&[40.0, 40.0, 40.0, 20.0, 20.0]);
669        assert_eq!(short.declining(), None);
670    }
671
672    #[test]
673    fn a_missing_week_is_a_gap_not_a_crash() {
674        // A fortnight off in the middle of a flat stretch. Reading the absent
675        // weeks as zeroes would drag the recent median to nothing and report a
676        // holiday as a collapse.
677        let holiday = person_with(&[40.0, 40.0, 40.0, 0.0, 0.0, 40.0, 40.0, 40.0]);
678        assert_eq!(holiday.declining(), None, "a gap is missing data, not falling hours");
679    }
680
681    #[test]
682    fn a_week_of_days_still_open_is_not_a_week_of_no_work() {
683        // The one zero that actually reaches the statistics: `GROUP BY` counts
684        // the days, but an unfinished day contributes no seconds, so the week
685        // arrives as `days_recorded = 5, worked_seconds = 0`. Reading it as
686        // "worked nothing" manufactures a collapse out of a week that is
687        // simply not filed yet - and, on the way back up, a recovery.
688        let filing_late = person_with_open(&[40.0, 40.0, 40.0, 40.0, 0.0]);
689        assert_eq!(filing_late.declining(), None, "an unfiled week is not a decline");
690
691        // And the case that decides the filter: a week of unfiled days among
692        // the recent ones. Counted as a zero it drags the recent median to the
693        // floor and reports a collapse that is really one week filed late -
694        // a signal about the agent, dressed up as one about the person.
695        let interrupted = person_with_open(&[40.0, 40.0, 40.0, 39.0, 0.0, 41.0]);
696        assert_eq!(interrupted.declining(), None, "an unfiled week must not be read as a week of no work");
697
698        // And it must not drag the median down either, or every ordinary week
699        // after it would start looking unusually long.
700        assert_eq!(filing_late.median(), Some(40 * 3600));
701    }
702
703    #[test]
704    fn silence_is_measured_from_the_last_real_day() {
705        let mut quiet = person_with(&[40.0, 40.0, 40.0, 40.0]);
706        // Stopped reporting a fortnight before the window closed.
707        quiet.last_day = Some(window_end() - chrono::Duration::days(14));
708        assert_eq!(quiet.quiet_for(window_end()), Some(14));
709
710        // Someone who worked to the end of the window is not "quiet" just
711        // because the window closes on a Sunday.
712        let working = person_with(&[40.0, 40.0, 40.0, 40.0]);
713        assert_eq!(working.quiet_for(window_end()), None);
714    }
715
716    #[test]
717    fn someone_who_never_reported_gets_no_silence_signal() {
718        // A different fact, and the dashboard already has better words for it:
719        // "never reported", next to an agent count. Saying "no data for 84
720        // days" about somebody who never had any would be arithmetic dressed
721        // up as an observation.
722        let mut never = person_with(&[]);
723        never.last_day = None;
724        assert_eq!(never.quiet_for(window_end()), None);
725    }
726
727    #[test]
728    fn an_unusual_week_is_unusual_in_either_direction() {
729        // Both ways on purpose: half the usual hours and half again are each
730        // worth a look, and flagging only the low one would make the signal an
731        // accusation rather than a question.
732        let low = person_with(&[40.0, 40.0, 40.0, 40.0, 20.0]);
733        let (week, median) = low.unusual_week(window_end()).expect("half the usual week is unusual");
734        assert_eq!(week, 20 * 3600);
735        assert_eq!(median, 40 * 3600);
736
737        let high = person_with(&[40.0, 40.0, 40.0, 40.0, 60.0]);
738        assert!(high.unusual_week(window_end()).is_some(), "a week half again as long is unusual too");
739
740        // A four-day week is about a fifth down and is nobody's business.
741        let ordinary = person_with(&[40.0, 40.0, 40.0, 40.0, 32.0]);
742        assert_eq!(ordinary.unusual_week(window_end()), None);
743    }
744
745    #[test]
746    fn too_little_history_means_no_median_and_no_signal() {
747        // Three weeks of history is not a person's "usual", and a signal drawn
748        // from it would be an opinion about a new hire.
749        let new_hire = person_with(&[40.0, 40.0, 10.0]);
750        assert_eq!(new_hire.median(), None);
751        assert_eq!(new_hire.unusual_week(window_end()), None, "no median, no comparison");
752    }
753
754    #[test]
755    fn an_empty_last_week_is_reported_as_silence_only_once() {
756        // Zero hours in the final week is silence, and `no_data` is the signal
757        // for that. Calling it "unusual" as well would state one fact twice
758        // and put the same person on the list under two headings.
759        let mut stopped = person_with(&[40.0, 40.0, 40.0, 40.0, 0.0]);
760        stopped.last_day = Some(window_end() - chrono::Duration::days(11));
761
762        assert_eq!(stopped.unusual_week(window_end()), None);
763        let kinds: Vec<SignalKind> = stopped.signals(window_end()).into_iter().map(|signal| signal.kind).collect();
764        assert_eq!(kinds, vec![SignalKind::NoData], "one fact, one signal");
765    }
766
767    #[test]
768    fn last_week_means_the_last_week_of_the_window() {
769        // Found by a live run, not by any of the tests above. `weeks` holds
770        // only the weeks somebody worked, so its final entry can be a
771        // fortnight old - and describing that as "last week" tells a manager
772        // about a week nobody is thinking about, alongside a `no_data` signal
773        // that says the person has been quiet since before it.
774        // A short week, and then nothing at all - the shape the demo's silent
775        // person really has. `person_with` drops the trailing zero the way the
776        // database drops a week nobody worked, so the newest week on record is
777        // the short one while the window has moved on past it.
778        let stopped = person_with(&[40.0, 40.0, 40.0, 40.0, 7.0, 0.0]);
779        assert_eq!(
780            stopped.weeks.last().map(|week| week.worked_seconds),
781            Some(7 * 3600),
782            "the fixture must leave the short week as the newest one on record,              or this test proves nothing"
783        );
784        assert!(
785            stopped.weeks.iter().all(|week| week.week_start < date(LAST_MONDAY)),
786            "and the window's own last week must be missing from the data"
787        );
788
789        assert_eq!(
790            stopped.unusual_week(window_end()),
791            None,
792            "a week that is not last week must not be reported as last week"
793        );
794    }
795
796    #[test]
797    fn a_steady_person_produces_nothing() {
798        // The case that must stay silent, or the list stops being read.
799        let steady = person_with(&[40.0, 39.0, 41.0, 40.0, 40.5]);
800        assert!(steady.signals(window_end()).is_empty(), "a steady person is not news");
801    }
802
803    #[test]
804    fn the_fading_person_from_the_demo_is_caught() {
805        // The shape the demo seeds on purpose: a full week at the start,
806        // five-hour days by the end. This milestone exists to point at it.
807        let fading = person_with(&[42.5, 40.0, 37.5, 32.5, 30.0, 25.0]);
808        let signals = fading.signals(window_end());
809
810        let declining = signals
811            .iter()
812            .find(|signal| signal.kind == SignalKind::Declining)
813            .expect("the fading person should be flagged");
814        assert!(declining.weeks.unwrap_or(0) >= 3, "{declining:?}");
815        assert!(
816            declining.from_seconds > declining.to_seconds,
817            "the figures must show the direction: {declining:?}"
818        );
819    }
820
821    #[test]
822    fn a_trend_keeps_its_empty_weeks() {
823        // A gap drawn as a gap is the point of the chart. Dropping empty weeks
824        // would close the hole up and make an absence look like continuity.
825        let person = person_with(&[40.0, 0.0, 40.0]);
826        let weeks = person.filled_weeks(date(LAST_MONDAY) - chrono::Duration::weeks(2), date(LAST_MONDAY));
827
828        assert_eq!(weeks.len(), 3, "every week in the window, not only the worked ones");
829        assert_eq!(weeks[1].worked_seconds, 0);
830        assert_eq!(weeks[1].days_recorded, 0, "zero days says the silence is real");
831    }
832
833    #[test]
834    fn signals_are_ordered_worst_first() {
835        // A manager reads top-down, so silence outranks a slide: an agent that
836        // stopped reporting means the other numbers about that person are not
837        // to be trusted either.
838        let mut kinds = [SignalKind::UnusualWeek, SignalKind::Declining, SignalKind::NoData];
839        kinds.sort_by_key(|kind| severity(*kind));
840        assert_eq!(kinds, [SignalKind::NoData, SignalKind::Declining, SignalKind::UnusualWeek]);
841    }
842}