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}