Skip to main content

kasl_server/
demo.rs

1//! A fictional team, so the dashboards can be seen before a single agent is
2//! installed on anybody's machine.
3//!
4//! Turned on with `KASL_DEMO=true`. On an empty database the server creates
5//! three departments, their managers and employees, an agent for each, and
6//! eight weeks of days shaped so every state the dashboard knows how to show
7//! is on screen at once: someone steady, someone working long days, someone
8//! whose hours are shrinking week by week, a day open right now, an agent gone
9//! silent, and one that never reported at all.
10//!
11//! On a database that already holds accounts it refuses to start (ADR 0013).
12//! The installation is marked as a demo in `settings`, which is what the web
13//! UI reads to say "nothing here is real" - the environment variable can be
14//! dropped later and the label stays.
15//!
16//! The team is generated, not stored: names, emails and the shape of every
17//! day live in this file, and the generator is deterministic for a given
18//! "today", so two demos started on the same date show the same numbers and a
19//! screenshot can be reproduced.
20//!
21//! Every person's history is written through `import::write_days` - the path
22//! an operator's own import takes - rather than through a private INSERT, so
23//! the demo exercises the same rows the dashboards were built on.
24
25use anyhow::{Context, Result, bail};
26use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
27use chrono::{DateTime, Datelike, Duration, NaiveDateTime, NaiveTime, Utc, Weekday};
28use serde::Serialize;
29use sqlx::PgPool;
30use uuid::Uuid;
31
32use crate::{
33    app::AppState,
34    audit,
35    auth::hash_token,
36    calendar::{CalendarDayKind, WorkdayKind},
37    error::ApiError,
38    heartbeat::{self, AgentState},
39    import::{self, AgentDay, AgentPause, AgentTask},
40    model::UserRole,
41    session::hash_password,
42};
43
44/// The one password every demo account signs in with.
45///
46/// Fixed and documented, not generated: a demo server has nothing to protect,
47/// and a visitor who has to dig three passwords out of a log before seeing a
48/// dashboard has been handed a chore instead of a demo.
49pub const PASSWORD: &str = "kasl-demo";
50
51/// The domain every demo address is under. Reserved by RFC 2606, so no demo
52/// account can ever be somebody's real one.
53const DOMAIN: &str = "example.com";
54
55/// How far back the history goes.
56const WEEKS: i64 = 8;
57
58/// How a person's days are shaped over the eight weeks.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum Pattern {
61    /// Roughly eight hours, a couple of interruptions, every weekday.
62    Steady,
63    /// Long days, the worst of them past the overwork threshold: the row the
64    /// manager should be looking at.
65    ///
66    /// The span reaches past half again the norm on purpose. At ten and a half
67    /// hours - which is what this was - the pattern was the row a manager
68    /// *would* look at and the one the alerts never mentioned, so the demo
69    /// showed one of the three rules and the milestone's own installation
70    /// could not demonstrate it.
71    Long,
72    /// A full week at the start, five-hour days by the end - the trend the
73    /// "trends and anomalies" milestone will point at.
74    Fading,
75    /// Steady, but the breaks are entered by hand, with reasons.
76    Breaks,
77    /// Six-tenths of a day, every weekday: what part time looks like against
78    /// a norm that knows about it. Paired with an entry in [`PART_TIME`] -
79    /// the shape of the days and the rate on the account have to agree, or
80    /// the demo draws either permanent overtime or permanent shortfall.
81    PartTime,
82    /// Working right now: a day open on today's date.
83    Open,
84    /// Steady, and then a day left open since yesterday morning: kasl still
85    /// running on a machine nobody is at.
86    ///
87    /// The state the "day not closed" alert exists for, and one no other
88    /// pattern produces. `Open` cannot double as it - that row is somebody
89    /// working right now, which the live column needs, and a day that is both
90    /// current and stale is not a thing.
91    Stranded,
92    /// Reported until a week ago, then nothing: the agent went quiet.
93    Silent,
94    /// Has an agent, has never sent a day.
95    Never,
96}
97
98impl Pattern {
99    /// Every pattern, so a test can hold the team against the set rather than
100    /// against a second copy of it that somebody has to remember to extend.
101    ///
102    /// The `match` below is what keeps it honest: a new variant fails to
103    /// compile here until it is added, which a `[...]` literal would not.
104    const ALL: [Self; 9] = [
105        Self::Steady,
106        Self::Long,
107        Self::Fading,
108        Self::Breaks,
109        Self::PartTime,
110        Self::Open,
111        Self::Stranded,
112        Self::Silent,
113        Self::Never,
114    ];
115
116    /// Exists only to fail compilation when a variant is added without being
117    /// put in [`Pattern::ALL`]. Never called.
118    #[allow(dead_code)]
119    fn exhaustive(self) -> usize {
120        let at = match self {
121            Self::Steady => 0,
122            Self::Long => 1,
123            Self::Fading => 2,
124            Self::Breaks => 3,
125            Self::PartTime => 4,
126            Self::Open => 5,
127            Self::Stranded => 6,
128            Self::Silent => 7,
129            Self::Never => 8,
130        };
131        debug_assert!(matches!(Self::ALL[at], p if p == self), "ALL and this match have drifted apart");
132        at
133    }
134}
135
136/// One member of the fictional team.
137struct Person {
138    first: &'static str,
139    last: &'static str,
140    role: UserRole,
141    /// `None` for the administrator, who runs the installation rather than
142    /// belonging to a department.
143    department: Option<&'static str>,
144    /// UTC offset in minutes. A distributed team, so the dashboard shows what
145    /// it looks like when "09:00" means five different instants.
146    offset_minutes: i32,
147    /// `None` for someone who has no agent at all.
148    pattern: Option<Pattern>,
149}
150
151impl Person {
152    fn email(&self) -> String {
153        format!("{}.{}@{DOMAIN}", self.first.to_ascii_lowercase(), self.last.to_ascii_lowercase())
154    }
155
156    fn display_name(&self) -> String {
157        format!("{} {}", self.first, self.last)
158    }
159
160    /// The agent's bearer token. Fixed like the password, and for the same
161    /// reason - and it means a real kasl can be pointed at the demo.
162    fn token(&self) -> String {
163        format!("demo-{}", self.first.to_ascii_lowercase())
164    }
165
166    fn offset(&self) -> chrono::FixedOffset {
167        chrono::FixedOffset::east_opt(self.offset_minutes * 60).expect("the offsets in the team table are valid")
168    }
169}
170
171/// A department and the email of the person who runs it.
172struct Department {
173    name: &'static str,
174    manager: &'static str,
175}
176
177const DEPARTMENTS: [Department; 3] = [
178    Department {
179        name: "Engineering",
180        manager: "priya.raman",
181    },
182    Department {
183        name: "Design",
184        manager: "daniel.okafor",
185    },
186    Department {
187        name: "Support",
188        manager: "elena.novak",
189    },
190];
191
192/// The team. Order matters for the generator's seeds and for nothing else.
193const TEAM: [Person; 12] = [
194    Person {
195        first: "Sam",
196        last: "Whitfield",
197        role: UserRole::Admin,
198        department: None,
199        offset_minutes: 0,
200        pattern: None,
201    },
202    Person {
203        first: "Priya",
204        last: "Raman",
205        role: UserRole::Manager,
206        department: Some("Engineering"),
207        offset_minutes: 5 * 60 + 30,
208        pattern: Some(Pattern::Steady),
209    },
210    Person {
211        first: "Daniel",
212        last: "Okafor",
213        role: UserRole::Manager,
214        department: Some("Design"),
215        offset_minutes: 60,
216        pattern: Some(Pattern::Steady),
217    },
218    Person {
219        first: "Elena",
220        last: "Novak",
221        role: UserRole::Manager,
222        department: Some("Support"),
223        offset_minutes: 2 * 60,
224        pattern: Some(Pattern::Long),
225    },
226    Person {
227        first: "Tomas",
228        last: "Verhoeven",
229        role: UserRole::Employee,
230        department: Some("Engineering"),
231        offset_minutes: 2 * 60,
232        pattern: Some(Pattern::Stranded),
233    },
234    Person {
235        first: "Aiko",
236        last: "Tanaka",
237        role: UserRole::Employee,
238        department: Some("Engineering"),
239        offset_minutes: 9 * 60,
240        pattern: Some(Pattern::Breaks),
241    },
242    Person {
243        first: "Lukas",
244        last: "Brandt",
245        role: UserRole::Employee,
246        department: Some("Engineering"),
247        offset_minutes: 60,
248        pattern: Some(Pattern::Fading),
249    },
250    Person {
251        first: "Sofia",
252        last: "Reyes",
253        role: UserRole::Employee,
254        department: Some("Engineering"),
255        offset_minutes: -3 * 60,
256        pattern: Some(Pattern::Open),
257    },
258    Person {
259        first: "Mira",
260        last: "Halvorsen",
261        role: UserRole::Employee,
262        department: Some("Design"),
263        offset_minutes: 2 * 60,
264        pattern: Some(Pattern::PartTime),
265    },
266    Person {
267        first: "Jonas",
268        last: "Petit",
269        role: UserRole::Employee,
270        department: Some("Design"),
271        offset_minutes: 60,
272        pattern: Some(Pattern::Silent),
273    },
274    Person {
275        first: "Yusuf",
276        last: "Demir",
277        role: UserRole::Employee,
278        department: Some("Support"),
279        offset_minutes: 3 * 60,
280        pattern: Some(Pattern::Long),
281    },
282    Person {
283        first: "Hana",
284        last: "Kowalski",
285        role: UserRole::Employee,
286        department: Some("Support"),
287        offset_minutes: 60,
288        pattern: Some(Pattern::Never),
289    },
290];
291
292/// What each department's people log their days against.
293fn task_pool(department: &str) -> &'static [&'static str] {
294    match department {
295        "Engineering" => &[
296            "Reliable ingest",
297            "Batch upload retries",
298            "Migration to the new query layer",
299            "Review: departments API",
300            "Flaky CI on arm64",
301            "Onboarding checklist",
302            "Release notes for 2.3",
303            "Connection pool tuning",
304        ],
305        "Design" => &[
306            "Dashboard empty states",
307            "Icon set, second pass",
308            "Login screen polish",
309            "Design tokens audit",
310            "Mobile layout study",
311            "Timeline colours in dark mode",
312        ],
313        _ => &[
314            "Ticket triage",
315            "Customer call: Northbridge",
316            "Knowledge base: backups",
317            "Escalation: lost password",
318            "Weekly support digest",
319            "Renewal follow-ups",
320        ],
321    }
322}
323
324/// Reasons a person gives for a break they entered by hand.
325const BREAK_REASONS: [&str; 5] = ["Lunch", "School run", "Dentist", "Walk", "Errand"];
326
327/// An account a visitor can sign in as.
328#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
329pub struct Account {
330    pub role: UserRole,
331    pub email: String,
332    pub display_name: String,
333}
334
335/// One of each role, in the order a visitor would try them: the manager's
336/// dashboard is what the demo exists to show, so it is not last.
337pub fn showcase() -> Vec<Account> {
338    [UserRole::Manager, UserRole::Employee, UserRole::Admin]
339        .into_iter()
340        .filter_map(|role| TEAM.iter().find(|person| person.role == role))
341        .map(|person| Account {
342            role: person.role,
343            email: person.email(),
344            display_name: person.display_name(),
345        })
346        .collect()
347}
348
349/// What the database says about itself before the demo touches it.
350#[derive(Debug, PartialEq, Eq)]
351pub enum Status {
352    /// No accounts: the demo may seed.
353    Empty,
354    /// Already the demo: start and say nothing.
355    Demo,
356    /// Somebody's real installation. The demo must not run here.
357    Populated { accounts: i64 },
358}
359
360pub async fn status(pool: &PgPool) -> Result<Status> {
361    let demo: bool = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton")
362        .fetch_one(pool)
363        .await
364        .context("failed to read the installation's settings")?;
365    if demo {
366        return Ok(Status::Demo);
367    }
368    let accounts: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
369        .fetch_one(pool)
370        .await
371        .context("failed to count accounts")?;
372    Ok(if accounts == 0 { Status::Empty } else { Status::Populated { accounts } })
373}
374
375/// What a seed created.
376#[derive(Debug, Default, PartialEq, Eq)]
377pub struct Seeded {
378    pub departments: usize,
379    pub people: usize,
380    pub days: usize,
381    /// Dated exceptions written into the production calendar.
382    pub calendar_days: usize,
383}
384
385/// Who works less than a full day, and how much less.
386///
387/// One person, deliberately: a demo where everybody is full time never shows
388/// what a norm does for part-time work, and a demo where half the team is
389/// would make it look like the common case.
390///
391/// The rate is not decoration - [`Pattern::PartTime`] shortens her days to
392/// match. A full-length day against a six-tenths norm would draw sixty per
393/// cent of overtime on every row, which is the opposite of what part time
394/// looks like.
395const PART_TIME: [(&str, &str); 1] = [("mira.halvorsen", "0.6")];
396
397/// How far ahead of the history a demo holiday is placed.
398///
399/// The calendar is seeded relative to the day of the seed, like everything
400/// else here: a fixed date would be inside the eight weeks this month and
401/// outside them in three (the same relativity the history already has).
402const HOLIDAY_WEEKS_AGO: [(i64, CalendarDayKind, &str); 3] = [
403    (5, CalendarDayKind::Holiday, "Spring holiday"),
404    (2, CalendarDayKind::ShortDay, "Holiday eve"),
405    (2, CalendarDayKind::WorkingWeekend, "Transferred working day"),
406];
407
408/// Seeds the team into an empty database.
409///
410/// `now` is the moment the history is built back from: the last eight weeks
411/// end yesterday, and the one open day is open as of this instant. Passed in
412/// rather than read from the clock so a test can pin it.
413///
414/// Refuses a database that holds accounts already - the check is here as
415/// well as in `status`, because this is the function that writes.
416pub async fn seed(pool: &PgPool, now: DateTime<Utc>) -> Result<Seeded> {
417    match status(pool).await? {
418        Status::Empty => {}
419        Status::Demo => bail!("this database already holds the demo team"),
420        Status::Populated { accounts } => bail!("this database already holds {accounts} accounts; the demo only seeds an empty one"),
421    }
422
423    let mut seeded = Seeded::default();
424
425    // People, departments and agents in one transaction, with the demo mark
426    // first: if the process dies between here and the last day written, the
427    // next start sees a demo and starts, rather than seeing accounts it did
428    // not make and refusing.
429    let mut tx = pool.begin().await?;
430    sqlx::query("UPDATE settings SET demo = true WHERE singleton").execute(&mut *tx).await?;
431
432    let mut department_ids = Vec::with_capacity(DEPARTMENTS.len());
433    for department in &DEPARTMENTS {
434        let id: Uuid = sqlx::query_scalar("INSERT INTO departments (name) VALUES ($1) RETURNING id")
435            .bind(department.name)
436            .fetch_one(&mut *tx)
437            .await
438            .with_context(|| format!("failed to create the {} department", department.name))?;
439        department_ids.push((department.name, id));
440        seeded.departments += 1;
441    }
442    let department_id = |name: &str| department_ids.iter().find(|(n, _)| *n == name).map(|(_, id)| *id);
443
444    // One hash for one password: argon2 is deliberately slow, and twelve of
445    // them at startup is a pause a visitor would notice.
446    let password_hash = hash_password(PASSWORD)?;
447
448    let mut user_ids = Vec::with_capacity(TEAM.len());
449    for person in &TEAM {
450        let id: Uuid = sqlx::query_scalar(
451            "INSERT INTO users (email, display_name, role, password_hash, active, department_id)
452             VALUES ($1, $2, $3, $4, true, $5) RETURNING id",
453        )
454        .bind(person.email())
455        .bind(person.display_name())
456        .bind(person.role)
457        .bind(&password_hash)
458        .bind(person.department.and_then(department_id))
459        .fetch_one(&mut *tx)
460        .await
461        .with_context(|| format!("failed to create the account for {}", person.display_name()))?;
462
463        if person.pattern.is_some() {
464            sqlx::query("INSERT INTO agents (user_id, name, token_hash) VALUES ($1, $2, $3)")
465                .bind(id)
466                .bind(format!("{}-laptop", person.first.to_ascii_lowercase()))
467                .bind(hash_token(&person.token()))
468                .execute(&mut *tx)
469                .await
470                .with_context(|| format!("failed to create the agent for {}", person.display_name()))?;
471        }
472
473        user_ids.push(id);
474        seeded.people += 1;
475    }
476
477    for (email_prefix, rate) in PART_TIME {
478        // Why one row on the dashboard is shorter than the rest, and how the
479        // screen says so rather than leaving it to be read as slacking.
480        let updated = sqlx::query("UPDATE users SET work_rate = $1::numeric WHERE email LIKE $2")
481            .bind(rate)
482            .bind(format!("{email_prefix}@%"))
483            .execute(&mut *tx)
484            .await?;
485        debug_assert_eq!(updated.rows_affected(), 1, "the part-time table names a member of the team");
486    }
487
488    // A calendar with something in it, placed relative to the seed like the
489    // history is. A demo with an empty calendar would show every weekday at a
490    // full norm, which is the one case the feature does not need to exist for.
491    let today = now.date_naive();
492    for (weeks_ago, kind, note) in HOLIDAY_WEEKS_AGO {
493        let date = weekday_near(today - Duration::weeks(weeks_ago), kind);
494        sqlx::query("INSERT INTO calendar_days (date, kind, note) VALUES ($1, $2, $3) ON CONFLICT (date) DO NOTHING")
495            .bind(date)
496            .bind(kind)
497            .bind(note)
498            .execute(&mut *tx)
499            .await?;
500        seeded.calendar_days += 1;
501    }
502
503    for department in &DEPARTMENTS {
504        let manager = TEAM
505            .iter()
506            .position(|person| person.email().starts_with(department.manager))
507            .expect("every department in the table names a member of the team");
508        sqlx::query("UPDATE departments SET manager_id = $1 WHERE name = $2")
509            .bind(user_ids[manager])
510            .bind(department.name)
511            .execute(&mut *tx)
512            .await?;
513    }
514    tx.commit().await?;
515
516    for (index, person) in TEAM.iter().enumerate() {
517        let Some(pattern) = person.pattern else { continue };
518        let days = days_for(person, pattern, index as u64, now);
519        // One commit per person: the same rows an import writes, without the
520        // per-day commit an import needs to survive failing halfway.
521        let mut tx = pool.begin().await?;
522        for day in &days {
523            import::write_day(&mut tx, user_ids[index], day, person.offset()).await?;
524        }
525        tx.commit().await?;
526        seeded.days += days.len();
527
528        // When the server last heard from this machine: the end of the last
529        // day for most, this very moment for the open one, never for the
530        // agent that never reported. The dashboard's "last data 2 d ago" and
531        // "never reported" come from here.
532        let last_seen: Option<DateTime<Utc>> = match pattern {
533            Pattern::Never => None,
534            Pattern::Open => Some(now),
535            _ => days
536                .iter()
537                .filter_map(|day| day.end)
538                .max()
539                .map(|end| import::at_offset(end, person.offset()).with_timezone(&Utc)),
540        };
541        let (pulse, age) = pulse_for(pattern);
542        sqlx::query(
543            "UPDATE agents SET last_seen_at = $2, heartbeat_state = $3, demo_pulse_age_seconds = $4,
544                    heartbeat_at = CASE WHEN $3 IS NULL THEN NULL ELSE now() - coalesce($4, 0) * interval '1 second' END,
545                    heartbeat_received_at = CASE WHEN $3 IS NULL THEN NULL ELSE now() - coalesce($4, 0) * interval '1 second' END
546             WHERE user_id = $1",
547        )
548        .bind(user_ids[index])
549        .bind(last_seen)
550        .bind(pulse)
551        .bind(age)
552        .execute(pool)
553        .await?;
554    }
555
556    audit::Entry::new(audit::action::DEMO_SEEDED)
557        .with(serde_json::json!({
558            "people": seeded.people,
559            "departments": seeded.departments,
560            "days": seeded.days,
561            "calendar_days": seeded.calendar_days,
562        }))
563        .record(pool)
564        .await;
565
566    Ok(seeded)
567}
568
569/// A date of the sort the kind needs, near the one asked for.
570///
571/// A holiday and a short day have to land on a weekday to mean anything - a
572/// holiday on a Sunday takes nothing away - and a working weekend has to land
573/// on a Saturday. Without this the demo's calendar would be seeded on whatever
574/// weekday the install happened to fall on, and a third of the time it would
575/// change no number on any screen.
576fn weekday_near(date: chrono::NaiveDate, kind: CalendarDayKind) -> chrono::NaiveDate {
577    let wants_weekend = kind == CalendarDayKind::WorkingWeekend;
578    let mut date = date;
579    for _ in 0..7 {
580        let weekend = matches!(date.weekday(), Weekday::Sat | Weekday::Sun);
581        if weekend == wants_weekend && (!wants_weekend || date.weekday() == Weekday::Sat) {
582            return date;
583        }
584        date += Duration::days(1);
585    }
586    date
587}
588
589/// The pulse a pattern gets, and how old it is kept.
590///
591/// Only the people whose day is happening now get a live one; everyone else
592/// has stopped for the day, which on a real installation is silence, and
593/// silence is what a visitor should see it look like (ADR 0014).
594fn pulse_for(pattern: Pattern) -> (Option<AgentState>, Option<i32>) {
595    let state = match pattern {
596        // Mid-day, at the keyboard - the row that reads "working".
597        Pattern::Open => Some(AgentState::Working),
598        // Also mid-day, but away from it: the demo needs both live states on
599        // screen, or "paused" would never be seen.
600        Pattern::Breaks => Some(AgentState::Paused),
601        // Their agent is up and reporting, they are simply done for the day.
602        // This is what distinguishes `idle` from `offline` - and the reason
603        // the dashboard needs both.
604        Pattern::Steady => Some(AgentState::Idle),
605        // The machine nobody is at. `idle` rather than `working`: kasl is up
606        // and reporting, and its watcher sees no activity - which is precisely
607        // why nobody closed the day. A live pulse matters here beyond the
608        // colour of one cell, because it is what keeps this row out of the
609        // silence alert: what is wrong with this person is an open day, and
610        // two alerts about it would be the server saying one thing twice.
611        Pattern::Stranded => Some(AgentState::Idle),
612        // A pulse that is deliberately old: the agent was running this morning
613        // and has stopped answering. That is the row a manager should look at
614        // first, and it is only visible if the demo carries one - "no pulse at
615        // all" reads as `unknown`, which says nothing about the person.
616        Pattern::Long | Pattern::Fading => Some(AgentState::Working),
617        // Silent and Never never sent one, which is `unknown`.
618        _ => None,
619    };
620    // Zero for the live ones; well past the threshold for the two that have
621    // stopped. Recorded on the row rather than recomputed, so the refresh
622    // knows which is which - a pulse that merely aged is indistinguishable
623    // from one seeded old.
624    let age = state.map(|_| {
625        if matches!(pattern, Pattern::Long | Pattern::Fading) {
626            STALE_PULSE_AGE_SECONDS
627        } else {
628            0
629        }
630    });
631    (state, age)
632}
633
634/// Gives the demo's agents their pulses if they have none.
635///
636/// The upgrade path. A demo seeded before this milestone has agents but no
637/// pulses, and bumping the image does not re-seed - so its dashboard would
638/// show twelve rows of "unknown" and none of the live column the version was
639/// released for. Found by deploying to the project's own demo stand, not by a
640/// test: every test seeds from empty, where the question cannot arise.
641///
642/// Idempotent, and it never overwrites a pulse that exists: an agent that has
643/// reported - including a real kasl pointed at the demo - is left alone. The
644/// people are matched by the email the generator assigns, so nothing outside
645/// the fictional team is touched.
646pub async fn ensure_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
647    let mut given = 0;
648    for person in TEAM.iter() {
649        let Some(pattern) = person.pattern else { continue };
650        let (Some(state), age) = pulse_for(pattern) else { continue };
651        let updated = sqlx::query(
652            "UPDATE agents SET heartbeat_state = $2, demo_pulse_age_seconds = $3,
653                    heartbeat_at = now() - coalesce($3, 0) * interval '1 second',
654                    heartbeat_received_at = now() - coalesce($3, 0) * interval '1 second'
655             FROM users u
656             WHERE agents.user_id = u.id AND lower(u.email) = lower($1)
657               AND agents.heartbeat_state IS NULL AND agents.revoked_at IS NULL",
658        )
659        .bind(person.email())
660        .bind(state)
661        .bind(age)
662        .execute(pool)
663        .await?;
664        given += updated.rows_affected();
665    }
666    Ok(given)
667}
668
669/// Gives an already-seeded demo its calendar and its part-time rate.
670///
671/// The upgrade path, and the second time this shape has been needed: a demo
672/// seeded before this milestone has people and days but no calendar and no
673/// rate, and bumping the image does not re-seed. Without this its dashboard
674/// shows twelve rows at a flat full norm - the one thing the version exists
675/// to show, missing on the one installation built to show it.
676///
677/// Idempotent and deliberately timid: it writes nothing if the calendar has
678/// any row at all, because an administrator may have entered a real one on
679/// top of the demo, and it never moves a rate somebody set by hand.
680pub async fn ensure_calendar(pool: &PgPool, now: DateTime<Utc>) -> Result<u64, sqlx::Error> {
681    let existing: i64 = sqlx::query_scalar("SELECT count(*) FROM calendar_days").fetch_one(pool).await?;
682    let mut written = 0;
683
684    if existing == 0 {
685        let today = now.date_naive();
686        for (weeks_ago, kind, note) in HOLIDAY_WEEKS_AGO {
687            let date = weekday_near(today - Duration::weeks(weeks_ago), kind);
688            let inserted = sqlx::query("INSERT INTO calendar_days (date, kind, note) VALUES ($1, $2, $3) ON CONFLICT (date) DO NOTHING")
689                .bind(date)
690                .bind(kind)
691                .bind(note)
692                .execute(pool)
693                .await?;
694            written += inserted.rows_affected();
695        }
696    }
697
698    for (email_prefix, rate) in PART_TIME {
699        // `= 1` rather than unconditional: a rate somebody set by hand is
700        // theirs, even on a demo.
701        let updated = sqlx::query("UPDATE users SET work_rate = $1::numeric WHERE email LIKE $2 AND work_rate = 1")
702            .bind(rate)
703            .bind(format!("{email_prefix}@%"))
704            .execute(pool)
705            .await?;
706        written += updated.rows_affected();
707    }
708
709    Ok(written)
710}
711
712/// Re-stamps the demo's seeded pulses so they stay fresh.
713///
714/// The demo is seeded once, but a pulse is believed for three minutes - so
715/// without this every live row on the demo would turn "offline" while the
716/// first visitor was still reading the page, and the milestone would be
717/// invisible on the one installation built to show it off.
718///
719/// Only rows that already carry a state are touched: which people are working,
720/// paused or idle was decided at seed time and stays decided, and an agent
721/// seeded silent must keep looking silent. Returns how many were re-stamped.
722///
723/// Only the pulses that were fresh when they were written are pulled forward.
724/// Two of the demo's agents are seeded deliberately stale - they are the "this
725/// machine has stopped answering" row a manager should look at first - and a
726/// refresh that pulled every stamp up to `now()` would quietly heal them,
727/// leaving the dashboard with no offline row to show. They are held at a fixed
728/// age instead, so they stay offline for as long as the demo runs.
729///
730/// Nothing like this runs on a real installation - there a pulse means an
731/// agent sent one, and a server that invented them would be lying about the
732/// only thing this endpoint is for.
733pub async fn refresh_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
734    // Each row is re-stamped to the age the seed chose for it, read off the
735    // row itself. Only agents the demo gave an age are touched, so an agent a
736    // visitor pointed at the demo keeps whatever pulse it actually sent.
737    let updated = sqlx::query(
738        "UPDATE agents
739         SET heartbeat_at = now() - demo_pulse_age_seconds * interval '1 second',
740             heartbeat_received_at = now() - demo_pulse_age_seconds * interval '1 second'
741         WHERE heartbeat_state IS NOT NULL AND demo_pulse_age_seconds IS NOT NULL AND revoked_at IS NULL",
742    )
743    .execute(pool)
744    .await?;
745    Ok(updated.rows_affected())
746}
747
748/// How old the demo's stopped agents are kept.
749///
750/// Comfortably past the staleness threshold, and stable across refreshes, so
751/// "this machine stopped answering" stays on the dashboard rather than healing
752/// itself a minute after the visitor arrives.
753const STALE_PULSE_AGE_SECONDS: i32 = (heartbeat::STALE_AFTER_SECONDS * 4) as i32;
754
755/// Keeps the demo's pulses fresh for as long as the server runs.
756///
757/// Started only when the installation is a demo. Re-stamps at half the
758/// staleness threshold, so a slow tick cannot let the dashboard flicker
759/// through "offline" between two refreshes.
760pub fn keep_pulses_fresh(pool: PgPool) {
761    let period = std::time::Duration::from_secs((heartbeat::STALE_AFTER_SECONDS / 2).max(1) as u64);
762    tokio::spawn(async move {
763        let mut ticker = tokio::time::interval(period);
764        // The first tick fires immediately and would re-stamp what `seed` just
765        // wrote; skipping it costs nothing and keeps the log quiet.
766        ticker.tick().await;
767        loop {
768            ticker.tick().await;
769            if let Err(error) = refresh_pulses(&pool).await {
770                // Worth a line, not worth stopping for: the dashboard degrades
771                // to "offline", which is at least an honest reading of a
772                // server that cannot reach its database.
773                tracing::warn!(%error, "failed to refresh the demo pulses");
774            }
775        }
776    });
777}
778
779/// Eight weeks of one person's days, ending yesterday.
780///
781/// Deterministic: the same person on the same date produces the same days.
782/// `index` seeds the generator so people do not share a stream and reordering
783/// the team table does not reshape everyone's history.
784fn days_for(person: &Person, pattern: Pattern, index: u64, now: DateTime<Utc>) -> Vec<AgentDay> {
785    if pattern == Pattern::Never {
786        return Vec::new();
787    }
788
789    let mut rng = Rng::new(index);
790    let now_local = now.with_timezone(&person.offset()).naive_local();
791    let today = now_local.date();
792    let first = today - Duration::weeks(WEEKS);
793    let pool = task_pool(person.department.unwrap_or("Support"));
794
795    // Tasks not yet finished, carried into the next day the way kasl carries
796    // them: same group id, higher completeness.
797    let mut carried: Vec<(i32, &'static str, i16)> = Vec::new();
798    let mut next_task_id = 1;
799    let mut days = Vec::new();
800
801    for offset in 0..(today - first).num_days() {
802        let date = first + Duration::days(offset);
803
804        if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
805            continue;
806        }
807        if pattern == Pattern::Silent && date >= today - Duration::days(7) {
808            continue;
809        }
810
811        // A day off now and then. Two shapes, and the difference is the point:
812        // a day the person marked as sick is a row saying so, and the norm
813        // excuses it (ADR 0017); a day nobody recorded at all is a gap, and
814        // the dashboard has to keep showing what that looks like.
815        //
816        // `chance` takes a percentage, not one-in-n. Written as `chance(4)`
817        // and `chance(2)` this came to four per cent of two per cent - eight
818        // days in ten thousand - and the demo seeded no leave at all, which a
819        // count against the seeded database caught and nothing else could.
820        if rng.chance(8) {
821            if rng.chance(60) {
822                days.push(AgentDay {
823                    date,
824                    // A day off still has a date and a shape: the agent files
825                    // it at the hour the day would have started, with no
826                    // hours on it.
827                    start: date.and_time(minutes(9 * 60)),
828                    end: Some(date.and_time(minutes(9 * 60))),
829                    pauses: Vec::new(),
830                    tasks: Vec::new(),
831                    kind: WorkdayKind::Sick,
832                });
833            }
834            continue;
835        }
836
837        let week = ((date - first).num_days() / 7) as f64;
838        let (start_minute, span_minutes) = match pattern {
839            // Up to thirteen and a half hours at the top of the range, so the
840            // worst days clear the 1.5x overwork bar (twelve hours against the
841            // eight-hour norm) while the ordinary ones stay under it. Both
842            // sides matter: a pattern always over the line would make the
843            // alert look like a property of the person rather than of a day.
844            Pattern::Long => (rng.range(8 * 60, 8 * 60 + 30), rng.range(10 * 60, 13 * 60 + 30)),
845            Pattern::Fading => (rng.range(9 * 60, 9 * 60 + 45), (8.5 * 60.0 - week * 0.45 * 60.0) as i64 + rng.range(-15, 15)),
846            // Six-tenths of a full day plus the usual wobble, starting late
847            // morning: the shape that matches the rate on her account.
848            Pattern::PartTime => (rng.range(10 * 60, 10 * 60 + 30), rng.range(4 * 60 + 40, 5 * 60 + 10)),
849            _ => (rng.range(8 * 60 + 45, 9 * 60 + 30), rng.range(7 * 60 + 45, 8 * 60 + 45)),
850        };
851        let start = date.and_time(minutes(start_minute));
852        let end = start + Duration::minutes(span_minutes);
853
854        let pauses = pauses_for(&mut rng, pattern, start, span_minutes);
855        let tasks = tasks_for(&mut rng, pool, &mut carried, &mut next_task_id, end);
856
857        days.push(AgentDay {
858            date,
859            start,
860            end: Some(end),
861            pauses,
862            tasks,
863            kind: WorkdayKind::Work,
864        });
865    }
866
867    if pattern == Pattern::Stranded {
868        // Yesterday morning, and never closed. Not "a long day": the day is
869        // still open now, which is what makes every total it will eventually
870        // produce wrong, and what the alert is about.
871        //
872        // The last finished day above lands on yesterday for a weekday run, so
873        // this replaces it rather than sitting beside it - one person has one
874        // day per date, and the second would be refused at ingest.
875        let stranded_date = today - Duration::days(1);
876        // Keeps the day's own lunch and tasks: what makes it stranded is the
877        // missing `ended_at`, not an empty day. A bare row would also be a day
878        // with no pauses and no tasks, which is a shape the employee's own
879        // screen does not otherwise produce and would be a second, accidental
880        // fiction.
881        let salvaged = days.iter().position(|day| day.date == stranded_date).map(|at| days.remove(at));
882        let (start, pauses, tasks) = match salvaged {
883            Some(day) => (
884                day.start,
885                // Pauses that ended: the ones inside the day that was actually
886                // worked. An open pause would say somebody is on a break right
887                // now, on a machine nobody has touched since yesterday.
888                day.pauses.into_iter().filter(|pause| pause.end.is_some()).collect(),
889                day.tasks,
890            ),
891            None => (stranded_date.and_time(minutes(8 * 60 + 40)), Vec::new(), Vec::new()),
892        };
893        days.push(AgentDay {
894            date: stranded_date,
895            start,
896            end: None,
897            pauses,
898            kind: WorkdayKind::Work,
899            tasks,
900        });
901    }
902
903    if pattern == Pattern::Open {
904        // Started three hours ago, or at midnight if the day is younger than
905        // that: an open day's start cannot lie on yesterday's date, which has
906        // its own row.
907        let start = (now_local - Duration::hours(3)).max(today.and_time(NaiveTime::MIN));
908        let elapsed = (now_local - start).num_minutes();
909        let mut pauses = Vec::new();
910        if elapsed > 90 {
911            let pause_start = start + Duration::minutes(elapsed / 2);
912            pauses.push(AgentPause {
913                start: pause_start,
914                end: Some(pause_start + Duration::minutes(11)),
915                duration_seconds: Some(11 * 60),
916                manual: false,
917                reason: None,
918            });
919        }
920        let name = carried.first().map(|(_, name, _)| *name).unwrap_or(pool[0]);
921        let group = carried.first().map(|(group, _, _)| *group).unwrap_or(next_task_id);
922        days.push(AgentDay {
923            date: today,
924            start,
925            end: None,
926            pauses,
927            kind: WorkdayKind::Work,
928            tasks: vec![AgentTask {
929                agent_task_id: next_task_id,
930                agent_group_id: group,
931                recorded_at: now_local - Duration::minutes(elapsed.min(20)),
932                name: name.to_string(),
933                comment: None,
934                completeness: 40,
935            }],
936        });
937    }
938
939    days
940}
941
942/// The interruptions of one day.
943///
944/// Placed on hourly slots so they never overlap: each starts within the first
945/// half hour of its slot and lasts under half an hour. Lunch sits near the
946/// middle of the day; whether it is a detected absence or a break the person
947/// entered depends on the pattern.
948fn pauses_for(rng: &mut Rng, pattern: Pattern, start: NaiveDateTime, span_minutes: i64) -> Vec<AgentPause> {
949    let manual = pattern == Pattern::Breaks;
950    let hours = span_minutes / 60;
951    let mut pauses = Vec::new();
952
953    // The fourth hour of an eight-hour day, and the middle of a shorter one.
954    // A fixed fourth hour put a part-timer's lunch forty minutes before they
955    // stopped, and a forty-minute lunch would then have run past the end of
956    // the day it was inside.
957    let lunch_hour = (hours / 2).clamp(1, 4);
958    let lunch = start + Duration::hours(lunch_hour) + Duration::minutes(rng.range(0, 15));
959    pauses.push(pause(lunch, rng.range(28, 40), manual, manual.then_some("Lunch")));
960
961    let idle_count = match pattern {
962        Pattern::Long => rng.range(1, 2),
963        Pattern::Breaks => rng.range(1, 3),
964        _ => rng.range(2, 3),
965    };
966    let mut slots: Vec<i64> = (1..hours).filter(|slot| *slot != lunch_hour).collect();
967    for _ in 0..idle_count {
968        if slots.is_empty() {
969            break;
970        }
971        let slot = slots.remove(rng.range(0, slots.len() as i64 - 1) as usize);
972        let at = start + Duration::hours(slot) + Duration::minutes(rng.range(0, 30));
973        let reason = manual.then(|| *rng.pick(&BREAK_REASONS[1..]));
974        pauses.push(pause(at, rng.range(5, 25), manual, reason));
975    }
976
977    pauses.sort_by_key(|pause| pause.start);
978    pauses
979}
980
981fn pause(start: NaiveDateTime, minutes: i64, manual: bool, reason: Option<&str>) -> AgentPause {
982    AgentPause {
983        start,
984        end: Some(start + Duration::minutes(minutes)),
985        duration_seconds: Some((minutes * 60) as i32),
986        manual,
987        reason: reason.map(str::to_string),
988    }
989}
990
991/// The tasks logged at the end of one day.
992///
993/// About half the time a task continues from the day before, so the history
994/// shows work carried across days the way kasl records it; the rest are new.
995/// Unfinished ones are carried forward, two at most.
996fn tasks_for(rng: &mut Rng, pool: &[&'static str], carried: &mut Vec<(i32, &'static str, i16)>, next_id: &mut i32, end: NaiveDateTime) -> Vec<AgentTask> {
997    let count = rng.range(2, 4);
998    let mut tasks = Vec::new();
999    let mut still_open = Vec::new();
1000
1001    for _ in 0..count {
1002        let continued = !carried.is_empty() && rng.chance(55);
1003        let (group, name, completeness) = if continued {
1004            let (group, name, done) = carried.remove(0);
1005            (group, name, (done + rng.range(20, 50) as i16).min(100))
1006        } else {
1007            let name = *rng.pick(pool);
1008            (*next_id, name, [20i16, 40, 60, 80, 100][rng.range(0, 4) as usize])
1009        };
1010        let id = *next_id;
1011        *next_id += 1;
1012
1013        tasks.push(AgentTask {
1014            agent_task_id: id,
1015            agent_group_id: group,
1016            recorded_at: end - Duration::minutes(rng.range(2, 15)),
1017            name: name.to_string(),
1018            comment: None,
1019            completeness,
1020        });
1021        if completeness < 100 && still_open.len() < 2 {
1022            still_open.push((group, name, completeness));
1023        }
1024    }
1025
1026    *carried = still_open;
1027    tasks
1028}
1029
1030fn minutes(since_midnight: i64) -> NaiveTime {
1031    NaiveTime::from_num_seconds_from_midnight_opt((since_midnight * 60) as u32, 0).expect("a minute of the day")
1032}
1033
1034/// A small deterministic generator (xorshift64*).
1035///
1036/// Not `rand`: its output for a given seed is not promised to stay the same
1037/// across versions, and the point of seeding is that the demo looks the same
1038/// after a dependency update as before it.
1039struct Rng(u64);
1040
1041impl Rng {
1042    fn new(seed: u64) -> Self {
1043        // Mixed so that neighbouring seeds do not produce neighbouring streams;
1044        // the `| 1` keeps zero - the one state xorshift never leaves - out.
1045        Self((seed + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
1046    }
1047
1048    fn next(&mut self) -> u64 {
1049        let mut x = self.0;
1050        x ^= x >> 12;
1051        x ^= x << 25;
1052        x ^= x >> 27;
1053        self.0 = x;
1054        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
1055    }
1056
1057    /// A value in `low..=high`.
1058    fn range(&mut self, low: i64, high: i64) -> i64 {
1059        debug_assert!(low <= high);
1060        low + (self.next() % (high - low + 1) as u64) as i64
1061    }
1062
1063    fn chance(&mut self, percent: u64) -> bool {
1064        self.next() % 100 < percent
1065    }
1066
1067    fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
1068        &items[self.range(0, items.len() as i64 - 1) as usize]
1069    }
1070}
1071
1072/// What `GET /api/v1/demo/accounts` answers on a demo.
1073#[derive(Debug, Serialize)]
1074struct Accounts {
1075    password: &'static str,
1076    accounts: Vec<Account>,
1077}
1078
1079/// The accounts a visitor may sign in as, and their password.
1080///
1081/// Unauthenticated on purpose - it is what the login screen shows before
1082/// anyone is signed in - and answered only where the database says this is a
1083/// demo, so a real installation with a real team never lists anybody here.
1084pub async fn accounts(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
1085    let demo: bool = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton").fetch_one(&state.pool).await?;
1086    if !demo {
1087        return Err(ApiError::new(StatusCode::NOT_FOUND, "this server is not a demo"));
1088    }
1089    Ok(Json(Accounts {
1090        password: PASSWORD,
1091        accounts: showcase(),
1092    }))
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097    use super::*;
1098
1099    fn at(date: &str, time: &str) -> DateTime<Utc> {
1100        format!("{date}T{time}Z").parse().expect("a test timestamp")
1101    }
1102
1103    fn person(pattern: Pattern) -> &'static Person {
1104        TEAM.iter().find(|person| person.pattern == Some(pattern)).expect("every pattern has a person")
1105    }
1106
1107    #[test]
1108    fn every_department_names_a_manager_who_is_in_the_team() {
1109        for department in &DEPARTMENTS {
1110            let manager = TEAM.iter().find(|person| person.email().starts_with(department.manager));
1111            let manager = manager.unwrap_or_else(|| panic!("{} names nobody in the team", department.name));
1112            assert_eq!(manager.role, UserRole::Manager, "{} is run by a {:?}", department.name, manager.role);
1113            assert_eq!(manager.department, Some(department.name), "a manager belongs to the department they run");
1114        }
1115    }
1116
1117    #[test]
1118    fn the_team_has_one_of_every_role_and_every_pattern() {
1119        for role in [UserRole::Admin, UserRole::Manager, UserRole::Employee] {
1120            assert!(TEAM.iter().any(|person| person.role == role), "nobody is a {role:?}");
1121        }
1122        // Every pattern the enum has, read off the enum rather than listed
1123        // here. A hand-written list is the shape of guard that stays green
1124        // while the world grows past it: `PartTime` was added and never
1125        // appeared here, so nothing would have noticed if nobody carried it.
1126        // `Pattern::ALL` is the one place the set is written down, and a
1127        // variant added without a person to show it fails on the same day.
1128        for pattern in Pattern::ALL {
1129            assert!(TEAM.iter().any(|person| person.pattern == Some(pattern)), "nobody is {pattern:?}");
1130        }
1131        assert_eq!(showcase().len(), 3, "one account of each role to try");
1132        assert_eq!(showcase()[0].role, UserRole::Manager, "the manager's dashboard is what the demo is for");
1133    }
1134
1135    #[test]
1136    fn emails_are_under_a_reserved_domain() {
1137        // The names are invented; the domain guarantees the addresses are too.
1138        for person in &TEAM {
1139            assert!(person.email().ends_with("@example.com"), "{}", person.email());
1140        }
1141        let mut emails: Vec<String> = TEAM.iter().map(Person::email).collect();
1142        emails.dedup();
1143        assert_eq!(emails.len(), TEAM.len(), "two people share an address");
1144    }
1145
1146    #[test]
1147    fn the_history_is_the_same_for_the_same_today() {
1148        // A screenshot taken from one demo must be reproducible on another
1149        // started the same day.
1150        let now = at("2026-08-29", "10:00:00");
1151        let person = person(Pattern::Steady);
1152        let one = days_for(person, Pattern::Steady, 4, now);
1153        let two = days_for(person, Pattern::Steady, 4, now);
1154        assert_eq!(one.len(), two.len());
1155        for (a, b) in one.iter().zip(&two) {
1156            assert_eq!(
1157                (a.date, a.start, a.end, a.pauses.len(), a.tasks.len()),
1158                (b.date, b.start, b.end, b.pauses.len(), b.tasks.len())
1159            );
1160        }
1161        assert!(
1162            one.len() >= 35,
1163            "eight weeks of weekdays, minus the odd sick day, is at least 35 days; got {}",
1164            one.len()
1165        );
1166    }
1167
1168    #[test]
1169    fn days_end_yesterday_and_skip_weekends() {
1170        let now = at("2026-08-29", "10:00:00");
1171        let person = person(Pattern::Steady);
1172        let days = days_for(person, Pattern::Steady, 4, now);
1173        let today = now.with_timezone(&person.offset()).date_naive();
1174        for day in &days {
1175            assert!(day.date < today, "{} is not in the past", day.date);
1176            assert!(!matches!(day.date.weekday(), Weekday::Sat | Weekday::Sun), "{} is a weekend", day.date);
1177            assert!(day.end.is_some(), "every past day is closed");
1178            let end = day.end.unwrap();
1179            for pause in &day.pauses {
1180                assert!(pause.start > day.start && pause.end.unwrap() < end, "a pause lies inside its day");
1181            }
1182            for pair in day.pauses.windows(2) {
1183                assert!(pair[0].end.unwrap() <= pair[1].start, "pauses do not overlap on {}", day.date);
1184            }
1185        }
1186    }
1187
1188    #[test]
1189    fn the_open_day_is_today_and_still_running() {
1190        let now = at("2026-08-29", "14:00:00");
1191        let person = person(Pattern::Open);
1192        let days = days_for(person, Pattern::Open, 7, now);
1193        let today = now.with_timezone(&person.offset()).date_naive();
1194        let open = days.iter().find(|day| day.end.is_none()).expect("one day is open");
1195        assert_eq!(open.date, today);
1196        assert!(open.start <= now.with_timezone(&person.offset()).naive_local());
1197        assert_eq!(days.iter().filter(|day| day.end.is_none()).count(), 1);
1198    }
1199
1200    #[test]
1201    fn an_open_day_started_after_midnight_does_not_reach_into_yesterday() {
1202        // 01:00 local: three hours ago is yesterday, which has its own row.
1203        let person = person(Pattern::Open);
1204        let now = at("2026-08-29", "04:00:00"); // 01:00 at UTC-3
1205        let days = days_for(person, Pattern::Open, 7, now);
1206        let open = days.iter().find(|day| day.end.is_none()).unwrap();
1207        assert_eq!(open.start, open.date.and_time(NaiveTime::MIN));
1208    }
1209
1210    #[test]
1211    fn the_silent_agent_stops_a_week_before_today() {
1212        let now = at("2026-08-29", "10:00:00");
1213        let person = person(Pattern::Silent);
1214        let days = days_for(person, Pattern::Silent, 9, now);
1215        let today = now.with_timezone(&person.offset()).date_naive();
1216        assert!(!days.is_empty());
1217        assert!(days.iter().all(|day| day.date < today - Duration::days(7)), "nothing in the last week");
1218    }
1219
1220    #[test]
1221    fn the_fading_hours_actually_fade() {
1222        let now = at("2026-08-29", "10:00:00");
1223        let person = person(Pattern::Fading);
1224        let days = days_for(person, Pattern::Fading, 6, now);
1225        // Worked days only. This is a claim about how long a working day is,
1226        // and a day off is zero minutes long by construction - one landing in
1227        // the first five would drag the average and fail a fade that is
1228        // happening exactly as intended.
1229        let worked: Vec<&AgentDay> = days.iter().filter(|day| day.kind == WorkdayKind::Work).collect();
1230        let span = |day: &&AgentDay| (day.end.unwrap() - day.start).num_minutes();
1231        let first_week: Vec<i64> = worked.iter().take(5).map(span).collect();
1232        let last_week: Vec<i64> = worked.iter().rev().take(5).map(span).collect();
1233        let average = |week: &[i64]| week.iter().sum::<i64>() / week.len() as i64;
1234        assert!(
1235            average(&first_week) - average(&last_week) > 120,
1236            "the last week should be hours shorter than the first: {first_week:?} vs {last_week:?}"
1237        );
1238    }
1239
1240    #[test]
1241    fn tasks_carry_across_days_under_one_group() {
1242        let now = at("2026-08-29", "10:00:00");
1243        let person = person(Pattern::Steady);
1244        let days = days_for(person, Pattern::Steady, 4, now);
1245        let mut ids = std::collections::HashSet::new();
1246        let mut carried = 0;
1247        for day in &days {
1248            for task in &day.tasks {
1249                assert!(ids.insert(task.agent_task_id), "agent_task_id {} repeats", task.agent_task_id);
1250                if task.agent_group_id != task.agent_task_id {
1251                    carried += 1;
1252                }
1253            }
1254        }
1255        assert!(carried > 0, "some work should span more than one day");
1256    }
1257
1258    #[test]
1259    fn the_never_pattern_has_no_days() {
1260        let person = person(Pattern::Never);
1261        assert!(days_for(person, Pattern::Never, 11, at("2026-08-29", "10:00:00")).is_empty());
1262    }
1263
1264    #[test]
1265    fn the_generator_stays_in_range() {
1266        let mut rng = Rng::new(3);
1267        for _ in 0..1000 {
1268            let value = rng.range(5, 7);
1269            assert!((5..=7).contains(&value));
1270        }
1271        let mut heads = 0;
1272        for _ in 0..1000 {
1273            if rng.chance(50) {
1274                heads += 1;
1275            }
1276        }
1277        assert!((350..=650).contains(&heads), "a 50% chance came up {heads} times in 1000");
1278    }
1279}