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/// How stale the demo's newest day may be before the whole team is regenerated.
670///
671/// Two days, so an ordinary weekend is not staleness: the fictional team works
672/// weekdays, and on a Sunday its newest day is rightly Friday's.
673const STALE_HISTORY_DAYS: i64 = 2;
674
675/// Whether the demo's history has stopped reaching the present.
676///
677/// The demo is generated once, anchored to the day it was seeded, and nothing
678/// has moved it since - so a stand left running for a fortnight shows a team
679/// that stopped working a fortnight ago. Every row reads "no days recorded for
680/// 16 days", which is a truthful description of the data and a false one of
681/// the product.
682pub async fn history_is_stale(pool: &PgPool, now: DateTime<Utc>) -> Result<bool, sqlx::Error> {
683    let newest: Option<chrono::NaiveDate> = sqlx::query_scalar("SELECT max(date) FROM workdays").fetch_one(pool).await?;
684    // No days at all is not staleness - it is a demo mid-seed, or one whose
685    // days somebody removed on purpose. Regenerating there would be this
686    // function inventing a reason.
687    let Some(newest) = newest else { return Ok(false) };
688    Ok((now.date_naive() - newest).num_days() > STALE_HISTORY_DAYS)
689}
690
691/// Throws the fictional team away and generates it again from today.
692///
693/// The third time this shape was needed decided its form. `ensure_pulses`
694/// (v0.17.1) and `ensure_calendar` (v0.21) each taught the demo one new field,
695/// and each time the version after brought another - because the thing being
696/// repaired was never the field. It is that the demo's history is anchored to
697/// the moment it was seeded, while the whole job of a shopfront is to show
698/// "this week".
699///
700/// Regenerating removes the class instead of the instance: whatever a later
701/// milestone adds to the seed arrives on the stand by itself, because the
702/// stand is not mended, it is born again. The generator is deterministic for a
703/// given "today" and writes through `import::write_days` - the same path a
704/// real import takes - so this exercises what ships.
705///
706/// What is lost is what a visitor clicked: an acknowledged alert, a password
707/// they changed. On a demo that is not a loss. "I dismissed this yesterday and
708/// it is back" describes the product more honestly than a team that has not
709/// worked since the third of September.
710///
711/// Refuses anything that is not already a demo, so a mistaken `KASL_DEMO` on a
712/// real installation cannot reach this.
713pub async fn reseed(pool: &PgPool, now: DateTime<Utc>) -> Result<Seeded> {
714    if !matches!(status(pool).await?, Status::Demo) {
715        bail!("only a demo installation is regenerated; this one is not one");
716    }
717
718    // Everything the seed creates, in one transaction. `users` cascades to
719    // agents, workdays, pauses, tasks and alerts; the rest are named because
720    // nothing points at them from `users`.
721    let mut tx = pool.begin().await?;
722    sqlx::query("DELETE FROM users").execute(&mut *tx).await?;
723    sqlx::query("DELETE FROM departments").execute(&mut *tx).await?;
724    sqlx::query("DELETE FROM calendar_days").execute(&mut *tx).await?;
725    sqlx::query("DELETE FROM tags").execute(&mut *tx).await?;
726    sqlx::query("DELETE FROM audit_log").execute(&mut *tx).await?;
727    // The mark comes off last inside the transaction, because `seed` refuses a
728    // database that still carries it. Either both happen or neither does: a
729    // process that dies here leaves a demo that is still a demo.
730    sqlx::query("UPDATE settings SET demo = false WHERE singleton").execute(&mut *tx).await?;
731    tx.commit().await?;
732
733    seed(pool, now).await
734}
735
736/// Gives an already-seeded demo its calendar and its part-time rate.
737///
738/// The upgrade path, and the second time this shape has been needed: a demo
739/// seeded before this milestone has people and days but no calendar and no
740/// rate, and bumping the image does not re-seed. Without this its dashboard
741/// shows twelve rows at a flat full norm - the one thing the version exists
742/// to show, missing on the one installation built to show it.
743///
744/// Idempotent and deliberately timid: it writes nothing if the calendar has
745/// any row at all, because an administrator may have entered a real one on
746/// top of the demo, and it never moves a rate somebody set by hand.
747pub async fn ensure_calendar(pool: &PgPool, now: DateTime<Utc>) -> Result<u64, sqlx::Error> {
748    let existing: i64 = sqlx::query_scalar("SELECT count(*) FROM calendar_days").fetch_one(pool).await?;
749    let mut written = 0;
750
751    if existing == 0 {
752        let today = now.date_naive();
753        for (weeks_ago, kind, note) in HOLIDAY_WEEKS_AGO {
754            let date = weekday_near(today - Duration::weeks(weeks_ago), kind);
755            let inserted = sqlx::query("INSERT INTO calendar_days (date, kind, note) VALUES ($1, $2, $3) ON CONFLICT (date) DO NOTHING")
756                .bind(date)
757                .bind(kind)
758                .bind(note)
759                .execute(pool)
760                .await?;
761            written += inserted.rows_affected();
762        }
763    }
764
765    for (email_prefix, rate) in PART_TIME {
766        // `= 1` rather than unconditional: a rate somebody set by hand is
767        // theirs, even on a demo.
768        let updated = sqlx::query("UPDATE users SET work_rate = $1::numeric WHERE email LIKE $2 AND work_rate = 1")
769            .bind(rate)
770            .bind(format!("{email_prefix}@%"))
771            .execute(pool)
772            .await?;
773        written += updated.rows_affected();
774    }
775
776    Ok(written)
777}
778
779/// Re-stamps the demo's seeded pulses so they stay fresh.
780///
781/// The demo is seeded once, but a pulse is believed for three minutes - so
782/// without this every live row on the demo would turn "offline" while the
783/// first visitor was still reading the page, and the milestone would be
784/// invisible on the one installation built to show it off.
785///
786/// Only rows that already carry a state are touched: which people are working,
787/// paused or idle was decided at seed time and stays decided, and an agent
788/// seeded silent must keep looking silent. Returns how many were re-stamped.
789///
790/// Only the pulses that were fresh when they were written are pulled forward.
791/// Two of the demo's agents are seeded deliberately stale - they are the "this
792/// machine has stopped answering" row a manager should look at first - and a
793/// refresh that pulled every stamp up to `now()` would quietly heal them,
794/// leaving the dashboard with no offline row to show. They are held at a fixed
795/// age instead, so they stay offline for as long as the demo runs.
796///
797/// Nothing like this runs on a real installation - there a pulse means an
798/// agent sent one, and a server that invented them would be lying about the
799/// only thing this endpoint is for.
800pub async fn refresh_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
801    // Each row is re-stamped to the age the seed chose for it, read off the
802    // row itself. Only agents the demo gave an age are touched, so an agent a
803    // visitor pointed at the demo keeps whatever pulse it actually sent.
804    let updated = sqlx::query(
805        "UPDATE agents
806         SET heartbeat_at = now() - demo_pulse_age_seconds * interval '1 second',
807             heartbeat_received_at = now() - demo_pulse_age_seconds * interval '1 second'
808         WHERE heartbeat_state IS NOT NULL AND demo_pulse_age_seconds IS NOT NULL AND revoked_at IS NULL",
809    )
810    .execute(pool)
811    .await?;
812    Ok(updated.rows_affected())
813}
814
815/// How old the demo's stopped agents are kept.
816///
817/// Comfortably past the staleness threshold, and stable across refreshes, so
818/// "this machine stopped answering" stays on the dashboard rather than healing
819/// itself a minute after the visitor arrives.
820const STALE_PULSE_AGE_SECONDS: i32 = (heartbeat::STALE_AFTER_SECONDS * 4) as i32;
821
822/// Keeps the demo's pulses fresh for as long as the server runs.
823///
824/// Started only when the installation is a demo. Re-stamps at half the
825/// staleness threshold, so a slow tick cannot let the dashboard flicker
826/// through "offline" between two refreshes.
827pub fn keep_pulses_fresh(pool: PgPool) {
828    let period = std::time::Duration::from_secs((heartbeat::STALE_AFTER_SECONDS / 2).max(1) as u64);
829    tokio::spawn(async move {
830        let mut ticker = tokio::time::interval(period);
831        // The first tick fires immediately and would re-stamp what `seed` just
832        // wrote; skipping it costs nothing and keeps the log quiet.
833        ticker.tick().await;
834        loop {
835            ticker.tick().await;
836            if let Err(error) = refresh_pulses(&pool).await {
837                // Worth a line, not worth stopping for: the dashboard degrades
838                // to "offline", which is at least an honest reading of a
839                // server that cannot reach its database.
840                tracing::warn!(%error, "failed to refresh the demo pulses");
841            }
842        }
843    });
844}
845
846/// Eight weeks of one person's days, ending yesterday.
847///
848/// Deterministic: the same person on the same date produces the same days.
849/// `index` seeds the generator so people do not share a stream and reordering
850/// the team table does not reshape everyone's history.
851fn days_for(person: &Person, pattern: Pattern, index: u64, now: DateTime<Utc>) -> Vec<AgentDay> {
852    if pattern == Pattern::Never {
853        return Vec::new();
854    }
855
856    let mut rng = Rng::new(index);
857    let now_local = now.with_timezone(&person.offset()).naive_local();
858    let today = now_local.date();
859    let first = today - Duration::weeks(WEEKS);
860    let pool = task_pool(person.department.unwrap_or("Support"));
861
862    // Tasks not yet finished, carried into the next day the way kasl carries
863    // them: same group id, higher completeness.
864    let mut carried: Vec<(i32, &'static str, i16)> = Vec::new();
865    let mut next_task_id = 1;
866    let mut days = Vec::new();
867
868    for offset in 0..(today - first).num_days() {
869        let date = first + Duration::days(offset);
870
871        if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
872            continue;
873        }
874        if pattern == Pattern::Silent && date >= today - Duration::days(7) {
875            continue;
876        }
877
878        // A day off now and then. Two shapes, and the difference is the point:
879        // a day the person marked as sick is a row saying so, and the norm
880        // excuses it (ADR 0017); a day nobody recorded at all is a gap, and
881        // the dashboard has to keep showing what that looks like.
882        //
883        // `chance` takes a percentage, not one-in-n. Written as `chance(4)`
884        // and `chance(2)` this came to four per cent of two per cent - eight
885        // days in ten thousand - and the demo seeded no leave at all, which a
886        // count against the seeded database caught and nothing else could.
887        if rng.chance(8) {
888            if rng.chance(60) {
889                days.push(AgentDay {
890                    date,
891                    // A day off still has a date and a shape: the agent files
892                    // it at the hour the day would have started, with no
893                    // hours on it.
894                    start: date.and_time(minutes(9 * 60)),
895                    end: Some(date.and_time(minutes(9 * 60))),
896                    pauses: Vec::new(),
897                    tasks: Vec::new(),
898                    kind: WorkdayKind::Sick,
899                });
900            }
901            continue;
902        }
903
904        let week = ((date - first).num_days() / 7) as f64;
905        let (start_minute, span_minutes) = match pattern {
906            // Up to thirteen and a half hours at the top of the range, so the
907            // worst days clear the 1.5x overwork bar (twelve hours against the
908            // eight-hour norm) while the ordinary ones stay under it. Both
909            // sides matter: a pattern always over the line would make the
910            // alert look like a property of the person rather than of a day.
911            Pattern::Long => (rng.range(8 * 60, 8 * 60 + 30), rng.range(10 * 60, 13 * 60 + 30)),
912            Pattern::Fading => (rng.range(9 * 60, 9 * 60 + 45), (8.5 * 60.0 - week * 0.45 * 60.0) as i64 + rng.range(-15, 15)),
913            // Six-tenths of a full day plus the usual wobble, starting late
914            // morning: the shape that matches the rate on her account.
915            Pattern::PartTime => (rng.range(10 * 60, 10 * 60 + 30), rng.range(4 * 60 + 40, 5 * 60 + 10)),
916            _ => (rng.range(8 * 60 + 45, 9 * 60 + 30), rng.range(7 * 60 + 45, 8 * 60 + 45)),
917        };
918        let start = date.and_time(minutes(start_minute));
919        let end = start + Duration::minutes(span_minutes);
920
921        let pauses = pauses_for(&mut rng, pattern, start, span_minutes);
922        let tasks = tasks_for(&mut rng, pool, &mut carried, &mut next_task_id, end);
923
924        days.push(AgentDay {
925            date,
926            start,
927            end: Some(end),
928            pauses,
929            tasks,
930            kind: WorkdayKind::Work,
931        });
932    }
933
934    if pattern == Pattern::Stranded {
935        // Yesterday morning, and never closed. Not "a long day": the day is
936        // still open now, which is what makes every total it will eventually
937        // produce wrong, and what the alert is about.
938        //
939        // The last finished day above lands on yesterday for a weekday run, so
940        // this replaces it rather than sitting beside it - one person has one
941        // day per date, and the second would be refused at ingest.
942        let stranded_date = today - Duration::days(1);
943        // Keeps the day's own lunch and tasks: what makes it stranded is the
944        // missing `ended_at`, not an empty day. A bare row would also be a day
945        // with no pauses and no tasks, which is a shape the employee's own
946        // screen does not otherwise produce and would be a second, accidental
947        // fiction.
948        let salvaged = days.iter().position(|day| day.date == stranded_date).map(|at| days.remove(at));
949        let (start, pauses, tasks) = match salvaged {
950            Some(day) => (
951                day.start,
952                // Pauses that ended: the ones inside the day that was actually
953                // worked. An open pause would say somebody is on a break right
954                // now, on a machine nobody has touched since yesterday.
955                day.pauses.into_iter().filter(|pause| pause.end.is_some()).collect(),
956                day.tasks,
957            ),
958            None => (stranded_date.and_time(minutes(8 * 60 + 40)), Vec::new(), Vec::new()),
959        };
960        days.push(AgentDay {
961            date: stranded_date,
962            start,
963            end: None,
964            pauses,
965            kind: WorkdayKind::Work,
966            tasks,
967        });
968    }
969
970    if pattern == Pattern::Open {
971        // Started three hours ago, or at midnight if the day is younger than
972        // that: an open day's start cannot lie on yesterday's date, which has
973        // its own row.
974        let start = (now_local - Duration::hours(3)).max(today.and_time(NaiveTime::MIN));
975        let elapsed = (now_local - start).num_minutes();
976        let mut pauses = Vec::new();
977        if elapsed > 90 {
978            let pause_start = start + Duration::minutes(elapsed / 2);
979            pauses.push(AgentPause {
980                start: pause_start,
981                end: Some(pause_start + Duration::minutes(11)),
982                duration_seconds: Some(11 * 60),
983                manual: false,
984                reason: None,
985            });
986        }
987        let name = carried.first().map(|(_, name, _)| *name).unwrap_or(pool[0]);
988        let group = carried.first().map(|(group, _, _)| *group).unwrap_or(next_task_id);
989        days.push(AgentDay {
990            date: today,
991            start,
992            end: None,
993            pauses,
994            kind: WorkdayKind::Work,
995            tasks: vec![AgentTask {
996                agent_task_id: next_task_id,
997                agent_group_id: group,
998                recorded_at: now_local - Duration::minutes(elapsed.min(20)),
999                name: name.to_string(),
1000                comment: None,
1001                completeness: 40,
1002            }],
1003        });
1004    }
1005
1006    days
1007}
1008
1009/// The interruptions of one day.
1010///
1011/// Placed on hourly slots so they never overlap: each starts within the first
1012/// half hour of its slot and lasts under half an hour. Lunch sits near the
1013/// middle of the day; whether it is a detected absence or a break the person
1014/// entered depends on the pattern.
1015fn pauses_for(rng: &mut Rng, pattern: Pattern, start: NaiveDateTime, span_minutes: i64) -> Vec<AgentPause> {
1016    let manual = pattern == Pattern::Breaks;
1017    let hours = span_minutes / 60;
1018    let mut pauses = Vec::new();
1019
1020    // The fourth hour of an eight-hour day, and the middle of a shorter one.
1021    // A fixed fourth hour put a part-timer's lunch forty minutes before they
1022    // stopped, and a forty-minute lunch would then have run past the end of
1023    // the day it was inside.
1024    let lunch_hour = (hours / 2).clamp(1, 4);
1025    let lunch = start + Duration::hours(lunch_hour) + Duration::minutes(rng.range(0, 15));
1026    pauses.push(pause(lunch, rng.range(28, 40), manual, manual.then_some("Lunch")));
1027
1028    let idle_count = match pattern {
1029        Pattern::Long => rng.range(1, 2),
1030        Pattern::Breaks => rng.range(1, 3),
1031        _ => rng.range(2, 3),
1032    };
1033    let mut slots: Vec<i64> = (1..hours).filter(|slot| *slot != lunch_hour).collect();
1034    for _ in 0..idle_count {
1035        if slots.is_empty() {
1036            break;
1037        }
1038        let slot = slots.remove(rng.range(0, slots.len() as i64 - 1) as usize);
1039        let at = start + Duration::hours(slot) + Duration::minutes(rng.range(0, 30));
1040        let reason = manual.then(|| *rng.pick(&BREAK_REASONS[1..]));
1041        pauses.push(pause(at, rng.range(5, 25), manual, reason));
1042    }
1043
1044    pauses.sort_by_key(|pause| pause.start);
1045    pauses
1046}
1047
1048fn pause(start: NaiveDateTime, minutes: i64, manual: bool, reason: Option<&str>) -> AgentPause {
1049    AgentPause {
1050        start,
1051        end: Some(start + Duration::minutes(minutes)),
1052        duration_seconds: Some((minutes * 60) as i32),
1053        manual,
1054        reason: reason.map(str::to_string),
1055    }
1056}
1057
1058/// The tasks logged at the end of one day.
1059///
1060/// About half the time a task continues from the day before, so the history
1061/// shows work carried across days the way kasl records it; the rest are new.
1062/// Unfinished ones are carried forward, two at most.
1063fn tasks_for(rng: &mut Rng, pool: &[&'static str], carried: &mut Vec<(i32, &'static str, i16)>, next_id: &mut i32, end: NaiveDateTime) -> Vec<AgentTask> {
1064    let count = rng.range(2, 4);
1065    let mut tasks = Vec::new();
1066    let mut still_open = Vec::new();
1067
1068    for _ in 0..count {
1069        let continued = !carried.is_empty() && rng.chance(55);
1070        let (group, name, completeness) = if continued {
1071            let (group, name, done) = carried.remove(0);
1072            (group, name, (done + rng.range(20, 50) as i16).min(100))
1073        } else {
1074            let name = *rng.pick(pool);
1075            (*next_id, name, [20i16, 40, 60, 80, 100][rng.range(0, 4) as usize])
1076        };
1077        let id = *next_id;
1078        *next_id += 1;
1079
1080        tasks.push(AgentTask {
1081            agent_task_id: id,
1082            agent_group_id: group,
1083            recorded_at: end - Duration::minutes(rng.range(2, 15)),
1084            name: name.to_string(),
1085            comment: None,
1086            completeness,
1087        });
1088        if completeness < 100 && still_open.len() < 2 {
1089            still_open.push((group, name, completeness));
1090        }
1091    }
1092
1093    *carried = still_open;
1094    tasks
1095}
1096
1097fn minutes(since_midnight: i64) -> NaiveTime {
1098    NaiveTime::from_num_seconds_from_midnight_opt((since_midnight * 60) as u32, 0).expect("a minute of the day")
1099}
1100
1101/// A small deterministic generator (xorshift64*).
1102///
1103/// Not `rand`: its output for a given seed is not promised to stay the same
1104/// across versions, and the point of seeding is that the demo looks the same
1105/// after a dependency update as before it.
1106struct Rng(u64);
1107
1108impl Rng {
1109    fn new(seed: u64) -> Self {
1110        // Mixed so that neighbouring seeds do not produce neighbouring streams;
1111        // the `| 1` keeps zero - the one state xorshift never leaves - out.
1112        Self((seed + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
1113    }
1114
1115    fn next(&mut self) -> u64 {
1116        let mut x = self.0;
1117        x ^= x >> 12;
1118        x ^= x << 25;
1119        x ^= x >> 27;
1120        self.0 = x;
1121        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
1122    }
1123
1124    /// A value in `low..=high`.
1125    fn range(&mut self, low: i64, high: i64) -> i64 {
1126        debug_assert!(low <= high);
1127        low + (self.next() % (high - low + 1) as u64) as i64
1128    }
1129
1130    fn chance(&mut self, percent: u64) -> bool {
1131        self.next() % 100 < percent
1132    }
1133
1134    fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
1135        &items[self.range(0, items.len() as i64 - 1) as usize]
1136    }
1137}
1138
1139/// What `GET /api/v1/demo/accounts` answers on a demo.
1140#[derive(Debug, Serialize)]
1141struct Accounts {
1142    password: &'static str,
1143    accounts: Vec<Account>,
1144}
1145
1146/// The accounts a visitor may sign in as, and their password.
1147///
1148/// Unauthenticated on purpose - it is what the login screen shows before
1149/// anyone is signed in - and answered only where the database says this is a
1150/// demo, so a real installation with a real team never lists anybody here.
1151pub async fn accounts(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
1152    let demo: bool = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton").fetch_one(&state.pool).await?;
1153    if !demo {
1154        return Err(ApiError::new(StatusCode::NOT_FOUND, "this server is not a demo"));
1155    }
1156    Ok(Json(Accounts {
1157        password: PASSWORD,
1158        accounts: showcase(),
1159    }))
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165
1166    fn at(date: &str, time: &str) -> DateTime<Utc> {
1167        format!("{date}T{time}Z").parse().expect("a test timestamp")
1168    }
1169
1170    fn person(pattern: Pattern) -> &'static Person {
1171        TEAM.iter().find(|person| person.pattern == Some(pattern)).expect("every pattern has a person")
1172    }
1173
1174    #[test]
1175    fn every_department_names_a_manager_who_is_in_the_team() {
1176        for department in &DEPARTMENTS {
1177            let manager = TEAM.iter().find(|person| person.email().starts_with(department.manager));
1178            let manager = manager.unwrap_or_else(|| panic!("{} names nobody in the team", department.name));
1179            assert_eq!(manager.role, UserRole::Manager, "{} is run by a {:?}", department.name, manager.role);
1180            assert_eq!(manager.department, Some(department.name), "a manager belongs to the department they run");
1181        }
1182    }
1183
1184    #[test]
1185    fn the_team_has_one_of_every_role_and_every_pattern() {
1186        for role in [UserRole::Admin, UserRole::Manager, UserRole::Employee] {
1187            assert!(TEAM.iter().any(|person| person.role == role), "nobody is a {role:?}");
1188        }
1189        // Every pattern the enum has, read off the enum rather than listed
1190        // here. A hand-written list is the shape of guard that stays green
1191        // while the world grows past it: `PartTime` was added and never
1192        // appeared here, so nothing would have noticed if nobody carried it.
1193        // `Pattern::ALL` is the one place the set is written down, and a
1194        // variant added without a person to show it fails on the same day.
1195        for pattern in Pattern::ALL {
1196            assert!(TEAM.iter().any(|person| person.pattern == Some(pattern)), "nobody is {pattern:?}");
1197        }
1198        assert_eq!(showcase().len(), 3, "one account of each role to try");
1199        assert_eq!(showcase()[0].role, UserRole::Manager, "the manager's dashboard is what the demo is for");
1200    }
1201
1202    #[test]
1203    fn emails_are_under_a_reserved_domain() {
1204        // The names are invented; the domain guarantees the addresses are too.
1205        for person in &TEAM {
1206            assert!(person.email().ends_with("@example.com"), "{}", person.email());
1207        }
1208        let mut emails: Vec<String> = TEAM.iter().map(Person::email).collect();
1209        emails.dedup();
1210        assert_eq!(emails.len(), TEAM.len(), "two people share an address");
1211    }
1212
1213    #[test]
1214    fn the_history_is_the_same_for_the_same_today() {
1215        // A screenshot taken from one demo must be reproducible on another
1216        // started the same day.
1217        let now = at("2026-08-29", "10:00:00");
1218        let person = person(Pattern::Steady);
1219        let one = days_for(person, Pattern::Steady, 4, now);
1220        let two = days_for(person, Pattern::Steady, 4, now);
1221        assert_eq!(one.len(), two.len());
1222        for (a, b) in one.iter().zip(&two) {
1223            assert_eq!(
1224                (a.date, a.start, a.end, a.pauses.len(), a.tasks.len()),
1225                (b.date, b.start, b.end, b.pauses.len(), b.tasks.len())
1226            );
1227        }
1228        assert!(
1229            one.len() >= 35,
1230            "eight weeks of weekdays, minus the odd sick day, is at least 35 days; got {}",
1231            one.len()
1232        );
1233    }
1234
1235    #[test]
1236    fn days_end_yesterday_and_skip_weekends() {
1237        let now = at("2026-08-29", "10:00:00");
1238        let person = person(Pattern::Steady);
1239        let days = days_for(person, Pattern::Steady, 4, now);
1240        let today = now.with_timezone(&person.offset()).date_naive();
1241        for day in &days {
1242            assert!(day.date < today, "{} is not in the past", day.date);
1243            assert!(!matches!(day.date.weekday(), Weekday::Sat | Weekday::Sun), "{} is a weekend", day.date);
1244            assert!(day.end.is_some(), "every past day is closed");
1245            let end = day.end.unwrap();
1246            for pause in &day.pauses {
1247                assert!(pause.start > day.start && pause.end.unwrap() < end, "a pause lies inside its day");
1248            }
1249            for pair in day.pauses.windows(2) {
1250                assert!(pair[0].end.unwrap() <= pair[1].start, "pauses do not overlap on {}", day.date);
1251            }
1252        }
1253    }
1254
1255    #[test]
1256    fn the_open_day_is_today_and_still_running() {
1257        let now = at("2026-08-29", "14:00:00");
1258        let person = person(Pattern::Open);
1259        let days = days_for(person, Pattern::Open, 7, now);
1260        let today = now.with_timezone(&person.offset()).date_naive();
1261        let open = days.iter().find(|day| day.end.is_none()).expect("one day is open");
1262        assert_eq!(open.date, today);
1263        assert!(open.start <= now.with_timezone(&person.offset()).naive_local());
1264        assert_eq!(days.iter().filter(|day| day.end.is_none()).count(), 1);
1265    }
1266
1267    #[test]
1268    fn an_open_day_started_after_midnight_does_not_reach_into_yesterday() {
1269        // 01:00 local: three hours ago is yesterday, which has its own row.
1270        let person = person(Pattern::Open);
1271        let now = at("2026-08-29", "04:00:00"); // 01:00 at UTC-3
1272        let days = days_for(person, Pattern::Open, 7, now);
1273        let open = days.iter().find(|day| day.end.is_none()).unwrap();
1274        assert_eq!(open.start, open.date.and_time(NaiveTime::MIN));
1275    }
1276
1277    #[test]
1278    fn the_silent_agent_stops_a_week_before_today() {
1279        let now = at("2026-08-29", "10:00:00");
1280        let person = person(Pattern::Silent);
1281        let days = days_for(person, Pattern::Silent, 9, now);
1282        let today = now.with_timezone(&person.offset()).date_naive();
1283        assert!(!days.is_empty());
1284        assert!(days.iter().all(|day| day.date < today - Duration::days(7)), "nothing in the last week");
1285    }
1286
1287    #[test]
1288    fn the_fading_hours_actually_fade() {
1289        let now = at("2026-08-29", "10:00:00");
1290        let person = person(Pattern::Fading);
1291        let days = days_for(person, Pattern::Fading, 6, now);
1292        // Worked days only. This is a claim about how long a working day is,
1293        // and a day off is zero minutes long by construction - one landing in
1294        // the first five would drag the average and fail a fade that is
1295        // happening exactly as intended.
1296        let worked: Vec<&AgentDay> = days.iter().filter(|day| day.kind == WorkdayKind::Work).collect();
1297        let span = |day: &&AgentDay| (day.end.unwrap() - day.start).num_minutes();
1298        let first_week: Vec<i64> = worked.iter().take(5).map(span).collect();
1299        let last_week: Vec<i64> = worked.iter().rev().take(5).map(span).collect();
1300        let average = |week: &[i64]| week.iter().sum::<i64>() / week.len() as i64;
1301        assert!(
1302            average(&first_week) - average(&last_week) > 120,
1303            "the last week should be hours shorter than the first: {first_week:?} vs {last_week:?}"
1304        );
1305    }
1306
1307    #[test]
1308    fn tasks_carry_across_days_under_one_group() {
1309        let now = at("2026-08-29", "10:00:00");
1310        let person = person(Pattern::Steady);
1311        let days = days_for(person, Pattern::Steady, 4, now);
1312        let mut ids = std::collections::HashSet::new();
1313        let mut carried = 0;
1314        for day in &days {
1315            for task in &day.tasks {
1316                assert!(ids.insert(task.agent_task_id), "agent_task_id {} repeats", task.agent_task_id);
1317                if task.agent_group_id != task.agent_task_id {
1318                    carried += 1;
1319                }
1320            }
1321        }
1322        assert!(carried > 0, "some work should span more than one day");
1323    }
1324
1325    #[test]
1326    fn the_never_pattern_has_no_days() {
1327        let person = person(Pattern::Never);
1328        assert!(days_for(person, Pattern::Never, 11, at("2026-08-29", "10:00:00")).is_empty());
1329    }
1330
1331    #[test]
1332    fn the_generator_stays_in_range() {
1333        let mut rng = Rng::new(3);
1334        for _ in 0..1000 {
1335            let value = rng.range(5, 7);
1336            assert!((5..=7).contains(&value));
1337        }
1338        let mut heads = 0;
1339        for _ in 0..1000 {
1340            if rng.chance(50) {
1341                heads += 1;
1342            }
1343        }
1344        assert!((350..=650).contains(&heads), "a 50% chance came up {heads} times in 1000");
1345    }
1346}