use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::{DateTime, NaiveDate, TimeDelta, Utc};
use rust_decimal::{Decimal, prelude::ToPrimitive};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::{
admin::{VISIBLE_USERS, require_manager_or_admin},
app::AppState,
audit,
calendar::{Calendar, WorkdayKind},
error::ApiError,
login::CurrentUser,
model::UserRole,
webhooks::{self, AlertPayload, Event, EventKind, Webhooks},
};
const SECONDS_PER_HOUR: i64 = 3600;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "alert_rule", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum AlertRule {
NoAgentData,
Overwork,
DayNotClosed,
}
impl AlertRule {
fn severity(self) -> u8 {
match self {
Self::NoAgentData => 0,
Self::DayNotClosed => 1,
Self::Overwork => 2,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "alert_state", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum AlertState {
Open,
Acknowledged,
Resolved,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::FromRow)]
pub struct Thresholds {
pub alert_silence_hours: i32,
pub alert_overwork_factor: Decimal,
pub alert_open_day_hours: i32,
}
impl Thresholds {
pub async fn load(pool: &PgPool) -> Result<Self, ApiError> {
Ok(
sqlx::query_as("SELECT alert_silence_hours, alert_overwork_factor, alert_open_day_hours FROM settings WHERE singleton")
.fetch_one(pool)
.await?,
)
}
fn silence_seconds(&self) -> i64 {
i64::from(self.alert_silence_hours) * SECONDS_PER_HOUR
}
fn open_day_seconds(&self) -> i64 {
i64::from(self.alert_open_day_hours) * SECONDS_PER_HOUR
}
}
#[derive(Debug, Clone)]
pub struct Observation {
pub user_id: Uuid,
pub last_seen_at: Option<DateTime<Utc>>,
pub has_live_agent: bool,
pub longest_finished_day: Option<(NaiveDate, i64, WorkdayKind)>,
pub open_day: Option<(NaiveDate, DateTime<Utc>)>,
pub work_rate: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub user_id: Uuid,
pub rule: AlertRule,
pub observed_seconds: i64,
pub against_seconds: Option<i64>,
pub subject_date: Option<NaiveDate>,
}
pub fn findings(observation: &Observation, calendar: &Calendar, standard_hours: Decimal, thresholds: &Thresholds, now: DateTime<Utc>) -> Vec<Finding> {
let mut found = Vec::new();
if let Some(silent_for) = silent_for(observation, thresholds, now) {
found.push(Finding {
user_id: observation.user_id,
rule: AlertRule::NoAgentData,
observed_seconds: silent_for,
against_seconds: Some(thresholds.silence_seconds()),
subject_date: None,
});
}
if let Some((date, worked, norm)) = overworked(observation, calendar, standard_hours, thresholds) {
found.push(Finding {
user_id: observation.user_id,
rule: AlertRule::Overwork,
observed_seconds: worked,
against_seconds: Some(norm),
subject_date: Some(date),
});
}
if let Some((date, open_for)) = open_too_long(observation, thresholds, now) {
found.push(Finding {
user_id: observation.user_id,
rule: AlertRule::DayNotClosed,
observed_seconds: open_for,
against_seconds: Some(thresholds.open_day_seconds()),
subject_date: Some(date),
});
}
found
}
fn silent_for(observation: &Observation, thresholds: &Thresholds, now: DateTime<Utc>) -> Option<i64> {
if !observation.has_live_agent {
return None;
}
let last_seen = observation.last_seen_at?;
let silent = (now - last_seen).num_seconds();
(silent >= thresholds.silence_seconds()).then_some(silent)
}
fn overworked(observation: &Observation, calendar: &Calendar, standard_hours: Decimal, thresholds: &Thresholds) -> Option<(NaiveDate, i64, i64)> {
let (date, worked, kind) = observation.longest_finished_day?;
if !kind.owes_the_norm() {
return None;
}
let norm = calendar.norm_seconds(date, standard_hours, observation.work_rate);
if norm <= 0 {
return None;
}
let threshold = (Decimal::from(norm) * thresholds.alert_overwork_factor).round().to_i64()?;
(worked >= threshold).then_some((date, worked, norm))
}
fn open_too_long(observation: &Observation, thresholds: &Thresholds, now: DateTime<Utc>) -> Option<(NaiveDate, i64)> {
let (date, started_at) = observation.open_day?;
let open_for = (now - started_at).num_seconds();
(open_for >= thresholds.open_day_seconds()).then_some((date, open_for))
}
const LOOKBACK_DAYS: i64 = 14;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct Swept {
pub raised: u64,
pub resolved: u64,
pub unchanged: u64,
}
pub async fn sweep(pool: &PgPool, now: DateTime<Utc>, webhooks: &Webhooks) -> Result<Swept, ApiError> {
let thresholds = Thresholds::load(pool).await?;
let standard_hours: Decimal = sqlx::query_scalar("SELECT standard_hours FROM settings WHERE singleton")
.fetch_one(pool)
.await?;
let today = now.date_naive();
let from = today - TimeDelta::days(LOOKBACK_DAYS);
let calendar = Calendar::load(pool, from, today).await?;
let observations = observe(pool, from, today).await?;
let acknowledged: Vec<(Uuid, AlertRule)> = sqlx::query_as("SELECT user_id, rule FROM alerts WHERE state = 'acknowledged' AND resolved_at IS NULL")
.fetch_all(pool)
.await?;
let found: Vec<Finding> = observations
.iter()
.flat_map(|observation| findings(observation, &calendar, standard_hours, &thresholds, now))
.collect();
let mut swept = Swept::default();
for finding in &found {
if acknowledged.contains(&(finding.user_id, finding.rule)) {
swept.unchanged += 1;
continue;
}
let mut tx = pool.begin().await?;
let inserted: Option<AlertPayload> = sqlx::query_as(
"INSERT INTO alerts (user_id, rule, observed_seconds, against_seconds, subject_date, fired_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, rule) WHERE state = 'open' DO NOTHING
RETURNING id, rule, observed_seconds, against_seconds, subject_date, fired_at",
)
.bind(finding.user_id)
.bind(finding.rule)
.bind(finding.observed_seconds)
.bind(finding.against_seconds)
.bind(finding.subject_date)
.bind(now)
.fetch_optional(&mut *tx)
.await?;
match inserted {
Some(alert) => {
announce(&mut tx, webhooks, EventKind::AlertRaised, finding.user_id, alert, None, now).await?;
tx.commit().await?;
swept.raised += 1;
tracing::info!(user = %finding.user_id, rule = ?finding.rule, "raised an alert");
}
None => {
tx.commit().await?;
swept.unchanged += 1;
}
}
}
let still_true: Vec<(Uuid, AlertRule)> = found.iter().map(|finding| (finding.user_id, finding.rule)).collect();
let standing: Vec<(Uuid, Uuid, AlertRule)> = sqlx::query_as("SELECT id, user_id, rule FROM alerts WHERE state IN ('open', 'acknowledged')")
.fetch_all(pool)
.await?;
for (id, user_id, rule) in standing {
if still_true.contains(&(user_id, rule)) {
continue;
}
let mut tx = pool.begin().await?;
let closed: Option<AlertPayload> = sqlx::query_as(
"UPDATE alerts SET state = 'resolved', resolved_at = $2 WHERE id = $1 AND state = 'open'
RETURNING id, rule, observed_seconds, against_seconds, subject_date, fired_at",
)
.bind(id)
.bind(now)
.fetch_optional(&mut *tx)
.await?;
if let Some(alert) = closed {
announce(&mut tx, webhooks, EventKind::AlertResolved, user_id, alert, None, now).await?;
tx.commit().await?;
swept.resolved += 1;
tracing::info!(user = %user_id, rule = ?rule, "an alert resolved itself");
continue;
}
let stamped: Option<AlertPayload> = sqlx::query_as(
"UPDATE alerts SET resolved_at = $2 WHERE id = $1 AND state = 'acknowledged' AND resolved_at IS NULL
RETURNING id, rule, observed_seconds, against_seconds, subject_date, fired_at",
)
.bind(id)
.bind(now)
.fetch_optional(&mut *tx)
.await?;
if let Some(alert) = stamped {
announce(&mut tx, webhooks, EventKind::AlertResolved, user_id, alert, None, now).await?;
}
tx.commit().await?;
}
Ok(swept)
}
async fn announce(
tx: &mut sqlx::PgConnection,
webhooks: &Webhooks,
step: EventKind,
user_id: Uuid,
alert: AlertPayload,
by: Option<String>,
now: DateTime<Utc>,
) -> Result<(), ApiError> {
if !webhooks.anyone_hears(step) {
return Ok(());
}
let person = webhooks::person(tx, user_id).await?;
webhooks::enqueue(tx, webhooks, &Event::alert(step, alert, person, by, webhooks, now)).await?;
Ok(())
}
async fn observe(pool: &PgPool, from: NaiveDate, to: NaiveDate) -> Result<Vec<Observation>, ApiError> {
let rows: Vec<ObservationRow> = sqlx::query_as(
"SELECT u.id AS user_id,
u.work_rate,
(SELECT max(greatest(a.last_seen_at, a.heartbeat_received_at))
FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS last_seen_at,
EXISTS (SELECT 1 FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS has_live_agent,
longest.date AS longest_date,
longest.worked AS longest_worked,
longest.kind AS longest_kind,
open.date AS open_date,
open.started_at AS open_started_at
FROM users u
-- The finished day in the window with the most seconds worked. One
-- day per person and not all of them: the rule fires on the worst,
-- and a manager does not need eleven rows to be told about a week of
-- eleven-hour days. The next one surfaces once this is answered.
LEFT JOIN LATERAL (
SELECT w.date,
w.kind,
EXTRACT(EPOCH FROM (w.ended_at - w.started_at))::bigint
- COALESCE((SELECT sum(EXTRACT(EPOCH FROM (p.ended_at - p.started_at)))::bigint
FROM pauses p WHERE p.workday_id = w.id AND p.ended_at IS NOT NULL), 0) AS worked
FROM workdays w
WHERE w.user_id = u.id AND w.date BETWEEN $1 AND $2 AND w.ended_at IS NOT NULL
ORDER BY worked DESC
LIMIT 1
) AS longest ON true
-- The open day, if there is one. Ordered so that if a schema ever let
-- a person have two, this picks the one that has been open longest -
-- the one worth saying something about.
LEFT JOIN LATERAL (
SELECT w.date, w.started_at
FROM workdays w
WHERE w.user_id = u.id AND w.ended_at IS NULL
ORDER BY w.started_at
LIMIT 1
) AS open ON true
WHERE u.active",
)
.bind(from)
.bind(to)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Observation::from).collect())
}
#[derive(Debug, sqlx::FromRow)]
struct ObservationRow {
user_id: Uuid,
work_rate: Decimal,
last_seen_at: Option<DateTime<Utc>>,
has_live_agent: bool,
longest_date: Option<NaiveDate>,
longest_worked: Option<i64>,
longest_kind: Option<WorkdayKind>,
open_date: Option<NaiveDate>,
open_started_at: Option<DateTime<Utc>>,
}
impl From<ObservationRow> for Observation {
fn from(row: ObservationRow) -> Self {
Self {
user_id: row.user_id,
last_seen_at: row.last_seen_at,
has_live_agent: row.has_live_agent,
longest_finished_day: match (row.longest_date, row.longest_worked, row.longest_kind) {
(Some(date), Some(worked), Some(kind)) => Some((date, worked.max(0), kind)),
_ => None,
},
open_day: row.open_date.zip(row.open_started_at),
work_rate: row.work_rate,
}
}
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Alert {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: String,
pub department: Option<String>,
pub rule: AlertRule,
pub state: AlertState,
pub fired_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
pub acknowledged_at: Option<DateTime<Utc>>,
pub acknowledged_by: Option<String>,
pub observed_seconds: i64,
pub against_seconds: Option<i64>,
pub subject_date: Option<NaiveDate>,
}
#[derive(Debug, Serialize)]
pub struct Feed {
pub alerts: Vec<Alert>,
pub open: i64,
pub people: i64,
pub thresholds: Thresholds,
}
#[derive(Debug, Deserialize)]
pub struct FeedQuery {
#[serde(default)]
pub state: Option<String>,
}
pub async fn feed(State(state): State<AppState>, user: CurrentUser, Query(query): Query<FeedQuery>) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
let wanted = query.state.as_deref().unwrap_or("open");
let filter = match wanted {
"open" => "AND al.state = 'open'",
"all" => "",
"acknowledged" => "AND al.state = 'acknowledged'",
"resolved" => "AND al.state = 'resolved'",
other => {
return Err(ApiError::bad_request(format!(
"unknown state `{other}`: expected open, acknowledged, resolved or all"
)));
}
};
let alerts: Vec<Alert> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT al.id, al.user_id, u.display_name, d.name AS department,
al.rule, al.state, al.fired_at, al.resolved_at, al.acknowledged_at,
ack.display_name AS acknowledged_by,
al.observed_seconds, al.against_seconds, al.subject_date
FROM alerts al
JOIN users u ON u.id = al.user_id
LEFT JOIN departments d ON d.id = u.department_id
LEFT JOIN users ack ON ack.id = al.acknowledged_by
WHERE {VISIBLE_USERS} {filter}
ORDER BY al.fired_at DESC
LIMIT 200"
)))
.bind(user.role == UserRole::Admin)
.bind(user.user_id)
.fetch_all(&state.pool)
.await?;
let open: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
"SELECT count(*) FROM alerts al JOIN users u ON u.id = al.user_id WHERE {VISIBLE_USERS} AND al.state = 'open'"
)))
.bind(user.role == UserRole::Admin)
.bind(user.user_id)
.fetch_one(&state.pool)
.await?;
let people: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!("SELECT count(*) FROM users u WHERE {VISIBLE_USERS} AND u.active")))
.bind(user.role == UserRole::Admin)
.bind(user.user_id)
.fetch_one(&state.pool)
.await?;
let mut alerts = alerts;
alerts.sort_by_key(|alert| (alert.rule.severity(), std::cmp::Reverse(alert.fired_at)));
let thresholds = Thresholds::load(&state.pool).await?;
Ok(Json(Feed {
alerts,
open,
people,
thresholds,
}))
}
pub async fn acknowledge(State(state): State<AppState>, user: CurrentUser, Path(id): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
let owned: Option<(Uuid, AlertRule)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT al.user_id, al.rule FROM alerts al JOIN users u ON u.id = al.user_id WHERE al.id = $3 AND {VISIBLE_USERS}"
)))
.bind(user.role == UserRole::Admin)
.bind(user.user_id)
.bind(id)
.fetch_optional(&state.pool)
.await?;
let Some((subject, rule)) = owned else {
return Err(ApiError::new(StatusCode::NOT_FOUND, "no such alert"));
};
let mut tx = state.pool.begin().await?;
let updated: Option<AlertPayload> = sqlx::query_as(
"UPDATE alerts SET state = 'acknowledged', acknowledged_at = now(), acknowledged_by = $2
WHERE id = $1 AND state = 'open'
RETURNING id, rule, observed_seconds, against_seconds, subject_date, fired_at",
)
.bind(id)
.bind(user.user_id)
.fetch_optional(&mut *tx)
.await?;
let Some(alert) = updated else {
return Err(ApiError::new(StatusCode::CONFLICT, "that alert is no longer open"));
};
if state.webhooks.anyone_hears(EventKind::AlertAcknowledged) {
let by: String = sqlx::query_scalar("SELECT display_name FROM users WHERE id = $1")
.bind(user.user_id)
.fetch_one(&mut *tx)
.await?;
announce(&mut tx, &state.webhooks, EventKind::AlertAcknowledged, subject, alert, Some(by), Utc::now()).await?;
}
tx.commit().await?;
audit::Entry::new(audit::action::ALERT_ACKNOWLEDGED)
.by(user.user_id)
.by_email(&user.email)
.on(subject)
.with(serde_json::json!({ "alert_id": id, "rule": rule }))
.record(&state.pool)
.await;
Ok((StatusCode::OK, Json(serde_json::json!({ "id": id, "state": AlertState::Acknowledged }))))
}
#[derive(Debug, Deserialize)]
pub struct ThresholdsInput {
pub alert_silence_hours: i32,
pub alert_overwork_factor: Decimal,
pub alert_open_day_hours: i32,
}
pub async fn put_thresholds(State(state): State<AppState>, user: CurrentUser, Json(input): Json<ThresholdsInput>) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
if input.alert_silence_hours <= 0 || input.alert_silence_hours > 720 {
return Err(ApiError::bad_request("silence is measured in whole hours, from 1 to 720"));
}
if input.alert_overwork_factor <= Decimal::ONE || input.alert_overwork_factor > Decimal::from(5) {
return Err(ApiError::bad_request("overwork is a share of the norm greater than 1 and at most 5"));
}
if input.alert_open_day_hours <= 0 || input.alert_open_day_hours > 168 {
return Err(ApiError::bad_request("an open day is measured in whole hours, from 1 to 168"));
}
let previous = Thresholds::load(&state.pool).await?;
sqlx::query("UPDATE settings SET alert_silence_hours = $1, alert_overwork_factor = $2, alert_open_day_hours = $3 WHERE singleton")
.bind(input.alert_silence_hours)
.bind(input.alert_overwork_factor)
.bind(input.alert_open_day_hours)
.execute(&state.pool)
.await?;
audit::Entry::new(audit::action::ALERT_THRESHOLDS_CHANGED)
.by(user.user_id)
.by_email(&user.email)
.with(serde_json::json!({
"from": previous,
"to": {
"alert_silence_hours": input.alert_silence_hours,
"alert_overwork_factor": input.alert_overwork_factor,
"alert_open_day_hours": input.alert_open_day_hours,
}
}))
.record(&state.pool)
.await;
let thresholds = Thresholds::load(&state.pool).await?;
Ok((StatusCode::OK, Json(thresholds)))
}
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
pub fn run_sweeps(pool: PgPool, webhooks: std::sync::Arc<Webhooks>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
match sweep(&pool, Utc::now(), &webhooks).await {
Ok(swept) if swept.raised > 0 || swept.resolved > 0 => {
tracing::info!(raised = swept.raised, resolved = swept.resolved, open = swept.unchanged, "swept the alerts");
}
Ok(_) => {}
Err(error) => tracing::warn!(%error, "an alert sweep failed; the next one will reconcile from scratch"),
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calendar::{CalendarDay, CalendarDayKind};
fn thresholds() -> Thresholds {
Thresholds {
alert_silence_hours: 12,
alert_overwork_factor: Decimal::new(15, 1),
alert_open_day_hours: 16,
}
}
fn now() -> DateTime<Utc> {
"2026-09-18T12:00:00Z".parse().expect("a fixed moment for the arithmetic")
}
fn quiet_observation() -> Observation {
Observation {
user_id: Uuid::nil(),
last_seen_at: Some(now() - TimeDelta::hours(1)),
has_live_agent: true,
longest_finished_day: Some(("2026-09-17".parse().unwrap(), 8 * SECONDS_PER_HOUR, WorkdayKind::Work)),
open_day: None,
work_rate: Decimal::ONE,
}
}
fn eight_hours() -> Decimal {
Decimal::from(8)
}
#[test]
fn an_ordinary_person_raises_nothing() {
let found = findings(&quiet_observation(), &Calendar::empty(), eight_hours(), &thresholds(), now());
assert!(found.is_empty(), "nothing here is worth interrupting anybody about: {found:?}");
}
#[test]
fn silence_past_the_threshold_is_an_alert_and_short_silence_is_not() {
let mut observation = quiet_observation();
observation.last_seen_at = Some(now() - TimeDelta::hours(11));
assert!(
!findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
.iter()
.any(|f| f.rule == AlertRule::NoAgentData),
"a night's silence is not an alert",
);
observation.last_seen_at = Some(now() - TimeDelta::hours(13));
let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
let silence = found
.iter()
.find(|f| f.rule == AlertRule::NoAgentData)
.expect("thirteen hours of silence is an alert");
assert_eq!(silence.observed_seconds, 13 * SECONDS_PER_HOUR);
assert_eq!(
silence.against_seconds,
Some(12 * SECONDS_PER_HOUR),
"the alert carries what it was measured against"
);
assert_eq!(silence.subject_date, None, "silence is about a person, not about a day");
}
#[test]
fn somebody_with_no_agent_is_not_silent() {
let mut observation = quiet_observation();
observation.has_live_agent = false;
observation.last_seen_at = Some(now() - TimeDelta::days(30));
assert!(
!findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
.iter()
.any(|f| f.rule == AlertRule::NoAgentData),
"nothing was ever asked of this person",
);
}
#[test]
fn overwork_is_measured_against_that_persons_own_norm() {
let mut observation = quiet_observation();
let date: NaiveDate = "2026-09-17".parse().unwrap();
observation.longest_finished_day = Some((date, 11 * SECONDS_PER_HOUR, WorkdayKind::Work));
assert!(
!findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
.iter()
.any(|f| f.rule == AlertRule::Overwork),
"eleven hours against an eight-hour norm is under the factor",
);
observation.longest_finished_day = Some((date, 12 * SECONDS_PER_HOUR, WorkdayKind::Work));
let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
let overwork = found
.iter()
.find(|f| f.rule == AlertRule::Overwork)
.expect("twelve hours is half again the norm");
assert_eq!(overwork.observed_seconds, 12 * SECONDS_PER_HOUR);
assert_eq!(
overwork.against_seconds,
Some(8 * SECONDS_PER_HOUR),
"the norm travels with the alert, not a percentage"
);
assert_eq!(overwork.subject_date, Some(date));
observation.work_rate = Decimal::new(5, 1);
observation.longest_finished_day = Some((date, 8 * SECONDS_PER_HOUR, WorkdayKind::Work));
let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
let overwork = found
.iter()
.find(|f| f.rule == AlertRule::Overwork)
.expect("a full day at half rate is double the norm");
assert_eq!(overwork.against_seconds, Some(4 * SECONDS_PER_HOUR));
}
#[test]
fn a_short_day_before_a_holiday_lowers_the_bar() {
let eve: NaiveDate = "2026-09-17".parse().unwrap();
let calendar = Calendar::from_days(vec![CalendarDay {
date: eve,
kind: CalendarDayKind::ShortDay,
note: None,
}]);
let mut observation = quiet_observation();
observation.longest_finished_day = Some((eve, 11 * SECONDS_PER_HOUR, WorkdayKind::Work));
let found = findings(&observation, &calendar, eight_hours(), &thresholds(), now());
let overwork = found
.iter()
.find(|f| f.rule == AlertRule::Overwork)
.expect("eleven hours against a seven-hour norm is overwork");
assert_eq!(overwork.against_seconds, Some(7 * SECONDS_PER_HOUR));
let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
assert!(
!found.iter().any(|f| f.rule == AlertRule::Overwork),
"the same eleven hours on a full-norm day is under the factor",
);
}
#[test]
fn a_day_the_calendar_does_not_ask_for_raises_no_overwork() {
let saturday: NaiveDate = "2026-09-19".parse().unwrap();
assert_eq!(saturday.format("%a").to_string(), "Sat", "the fixture has to actually be a Saturday");
let mut observation = quiet_observation();
observation.longest_finished_day = Some((saturday, 14 * SECONDS_PER_HOUR, WorkdayKind::Work));
assert!(
!findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
.iter()
.any(|f| f.rule == AlertRule::Overwork),
);
}
#[test]
fn a_day_of_leave_raises_no_overwork() {
let mut observation = quiet_observation();
for kind in [WorkdayKind::Vacation, WorkdayKind::Sick, WorkdayKind::DayOff] {
observation.longest_finished_day = Some(("2026-09-17".parse().unwrap(), 12 * SECONDS_PER_HOUR, kind));
assert!(
!findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
.iter()
.any(|f| f.rule == AlertRule::Overwork),
"{kind:?} owes no norm",
);
}
}
#[test]
fn a_day_open_too_long_is_an_alert() {
let mut observation = quiet_observation();
let date: NaiveDate = "2026-09-18".parse().unwrap();
observation.open_day = Some((date, now() - TimeDelta::hours(15)));
assert!(
!findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
.iter()
.any(|f| f.rule == AlertRule::DayNotClosed),
);
observation.open_day = Some((date, now() - TimeDelta::hours(17)));
let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
let open = found
.iter()
.find(|f| f.rule == AlertRule::DayNotClosed)
.expect("seventeen hours open is past any day");
assert_eq!(open.observed_seconds, 17 * SECONDS_PER_HOUR);
assert_eq!(open.against_seconds, Some(16 * SECONDS_PER_HOUR));
assert_eq!(open.subject_date, Some(date));
}
#[test]
fn an_open_day_is_judged_by_the_clock_and_not_by_a_norm() {
let mut observation = quiet_observation();
observation.work_rate = Decimal::new(5, 1);
observation.open_day = Some(("2026-09-18".parse().unwrap(), now() - TimeDelta::hours(17)));
assert!(
findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
.iter()
.any(|f| f.rule == AlertRule::DayNotClosed),
);
}
#[test]
fn the_thresholds_are_what_moves_the_line() {
let mut observation = quiet_observation();
observation.last_seen_at = Some(now() - TimeDelta::hours(5));
let strict = Thresholds {
alert_silence_hours: 4,
..thresholds()
};
assert!(
findings(&observation, &Calendar::empty(), eight_hours(), &strict, now())
.iter()
.any(|f| f.rule == AlertRule::NoAgentData),
"five hours of silence is an alert where the threshold is four",
);
assert!(
!findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
.iter()
.any(|f| f.rule == AlertRule::NoAgentData),
"and is not where the threshold is twelve",
);
}
#[test]
fn several_rules_can_be_true_at_once() {
let observation = Observation {
user_id: Uuid::nil(),
last_seen_at: Some(now() - TimeDelta::hours(20)),
has_live_agent: true,
longest_finished_day: Some(("2026-09-17".parse().unwrap(), 13 * SECONDS_PER_HOUR, WorkdayKind::Work)),
open_day: Some(("2026-09-18".parse().unwrap(), now() - TimeDelta::hours(20))),
work_rate: Decimal::ONE,
};
let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
assert_eq!(found.len(), 3, "silence, overwork and an open day are three separate things: {found:?}");
}
#[test]
fn silence_outranks_the_others_in_the_feed() {
let mut rules = [AlertRule::Overwork, AlertRule::NoAgentData, AlertRule::DayNotClosed];
rules.sort_by_key(|rule| rule.severity());
assert_eq!(rules, [AlertRule::NoAgentData, AlertRule::DayNotClosed, AlertRule::Overwork]);
}
}