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    error::ApiError,
37    heartbeat::{self, AgentState},
38    import::{self, AgentDay, AgentPause, AgentTask},
39    model::UserRole,
40    session::hash_password,
41};
42
43/// The one password every demo account signs in with.
44///
45/// Fixed and documented, not generated: a demo server has nothing to protect,
46/// and a visitor who has to dig three passwords out of a log before seeing a
47/// dashboard has been handed a chore instead of a demo.
48pub const PASSWORD: &str = "kasl-demo";
49
50/// The domain every demo address is under. Reserved by RFC 2606, so no demo
51/// account can ever be somebody's real one.
52const DOMAIN: &str = "example.com";
53
54/// How far back the history goes.
55const WEEKS: i64 = 8;
56
57/// How a person's days are shaped over the eight weeks.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum Pattern {
60    /// Roughly eight hours, a couple of interruptions, every weekday.
61    Steady,
62    /// Ten-hour days: the row the manager should be looking at.
63    Long,
64    /// A full week at the start, five-hour days by the end - the trend the
65    /// "trends and anomalies" milestone will point at.
66    Fading,
67    /// Steady, but the breaks are entered by hand, with reasons.
68    Breaks,
69    /// Working right now: a day open on today's date.
70    Open,
71    /// Reported until a week ago, then nothing: the agent went quiet.
72    Silent,
73    /// Has an agent, has never sent a day.
74    Never,
75}
76
77/// One member of the fictional team.
78struct Person {
79    first: &'static str,
80    last: &'static str,
81    role: UserRole,
82    /// `None` for the administrator, who runs the installation rather than
83    /// belonging to a department.
84    department: Option<&'static str>,
85    /// UTC offset in minutes. A distributed team, so the dashboard shows what
86    /// it looks like when "09:00" means five different instants.
87    offset_minutes: i32,
88    /// `None` for someone who has no agent at all.
89    pattern: Option<Pattern>,
90}
91
92impl Person {
93    fn email(&self) -> String {
94        format!("{}.{}@{DOMAIN}", self.first.to_ascii_lowercase(), self.last.to_ascii_lowercase())
95    }
96
97    fn display_name(&self) -> String {
98        format!("{} {}", self.first, self.last)
99    }
100
101    /// The agent's bearer token. Fixed like the password, and for the same
102    /// reason - and it means a real kasl can be pointed at the demo.
103    fn token(&self) -> String {
104        format!("demo-{}", self.first.to_ascii_lowercase())
105    }
106
107    fn offset(&self) -> chrono::FixedOffset {
108        chrono::FixedOffset::east_opt(self.offset_minutes * 60).expect("the offsets in the team table are valid")
109    }
110}
111
112/// A department and the email of the person who runs it.
113struct Department {
114    name: &'static str,
115    manager: &'static str,
116}
117
118const DEPARTMENTS: [Department; 3] = [
119    Department {
120        name: "Engineering",
121        manager: "priya.raman",
122    },
123    Department {
124        name: "Design",
125        manager: "daniel.okafor",
126    },
127    Department {
128        name: "Support",
129        manager: "elena.novak",
130    },
131];
132
133/// The team. Order matters for the generator's seeds and for nothing else.
134const TEAM: [Person; 12] = [
135    Person {
136        first: "Sam",
137        last: "Whitfield",
138        role: UserRole::Admin,
139        department: None,
140        offset_minutes: 0,
141        pattern: None,
142    },
143    Person {
144        first: "Priya",
145        last: "Raman",
146        role: UserRole::Manager,
147        department: Some("Engineering"),
148        offset_minutes: 5 * 60 + 30,
149        pattern: Some(Pattern::Steady),
150    },
151    Person {
152        first: "Daniel",
153        last: "Okafor",
154        role: UserRole::Manager,
155        department: Some("Design"),
156        offset_minutes: 60,
157        pattern: Some(Pattern::Steady),
158    },
159    Person {
160        first: "Elena",
161        last: "Novak",
162        role: UserRole::Manager,
163        department: Some("Support"),
164        offset_minutes: 2 * 60,
165        pattern: Some(Pattern::Long),
166    },
167    Person {
168        first: "Tomas",
169        last: "Verhoeven",
170        role: UserRole::Employee,
171        department: Some("Engineering"),
172        offset_minutes: 2 * 60,
173        pattern: Some(Pattern::Steady),
174    },
175    Person {
176        first: "Aiko",
177        last: "Tanaka",
178        role: UserRole::Employee,
179        department: Some("Engineering"),
180        offset_minutes: 9 * 60,
181        pattern: Some(Pattern::Breaks),
182    },
183    Person {
184        first: "Lukas",
185        last: "Brandt",
186        role: UserRole::Employee,
187        department: Some("Engineering"),
188        offset_minutes: 60,
189        pattern: Some(Pattern::Fading),
190    },
191    Person {
192        first: "Sofia",
193        last: "Reyes",
194        role: UserRole::Employee,
195        department: Some("Engineering"),
196        offset_minutes: -3 * 60,
197        pattern: Some(Pattern::Open),
198    },
199    Person {
200        first: "Mira",
201        last: "Halvorsen",
202        role: UserRole::Employee,
203        department: Some("Design"),
204        offset_minutes: 2 * 60,
205        pattern: Some(Pattern::Steady),
206    },
207    Person {
208        first: "Jonas",
209        last: "Petit",
210        role: UserRole::Employee,
211        department: Some("Design"),
212        offset_minutes: 60,
213        pattern: Some(Pattern::Silent),
214    },
215    Person {
216        first: "Yusuf",
217        last: "Demir",
218        role: UserRole::Employee,
219        department: Some("Support"),
220        offset_minutes: 3 * 60,
221        pattern: Some(Pattern::Long),
222    },
223    Person {
224        first: "Hana",
225        last: "Kowalski",
226        role: UserRole::Employee,
227        department: Some("Support"),
228        offset_minutes: 60,
229        pattern: Some(Pattern::Never),
230    },
231];
232
233/// What each department's people log their days against.
234fn task_pool(department: &str) -> &'static [&'static str] {
235    match department {
236        "Engineering" => &[
237            "Reliable ingest",
238            "Batch upload retries",
239            "Migration to the new query layer",
240            "Review: departments API",
241            "Flaky CI on arm64",
242            "Onboarding checklist",
243            "Release notes for 2.3",
244            "Connection pool tuning",
245        ],
246        "Design" => &[
247            "Dashboard empty states",
248            "Icon set, second pass",
249            "Login screen polish",
250            "Design tokens audit",
251            "Mobile layout study",
252            "Timeline colours in dark mode",
253        ],
254        _ => &[
255            "Ticket triage",
256            "Customer call: Northbridge",
257            "Knowledge base: backups",
258            "Escalation: lost password",
259            "Weekly support digest",
260            "Renewal follow-ups",
261        ],
262    }
263}
264
265/// Reasons a person gives for a break they entered by hand.
266const BREAK_REASONS: [&str; 5] = ["Lunch", "School run", "Dentist", "Walk", "Errand"];
267
268/// An account a visitor can sign in as.
269#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
270pub struct Account {
271    pub role: UserRole,
272    pub email: String,
273    pub display_name: String,
274}
275
276/// One of each role, in the order a visitor would try them: the manager's
277/// dashboard is what the demo exists to show, so it is not last.
278pub fn showcase() -> Vec<Account> {
279    [UserRole::Manager, UserRole::Employee, UserRole::Admin]
280        .into_iter()
281        .filter_map(|role| TEAM.iter().find(|person| person.role == role))
282        .map(|person| Account {
283            role: person.role,
284            email: person.email(),
285            display_name: person.display_name(),
286        })
287        .collect()
288}
289
290/// What the database says about itself before the demo touches it.
291#[derive(Debug, PartialEq, Eq)]
292pub enum Status {
293    /// No accounts: the demo may seed.
294    Empty,
295    /// Already the demo: start and say nothing.
296    Demo,
297    /// Somebody's real installation. The demo must not run here.
298    Populated { accounts: i64 },
299}
300
301pub async fn status(pool: &PgPool) -> Result<Status> {
302    let demo: bool = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton")
303        .fetch_one(pool)
304        .await
305        .context("failed to read the installation's settings")?;
306    if demo {
307        return Ok(Status::Demo);
308    }
309    let accounts: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
310        .fetch_one(pool)
311        .await
312        .context("failed to count accounts")?;
313    Ok(if accounts == 0 { Status::Empty } else { Status::Populated { accounts } })
314}
315
316/// What a seed created.
317#[derive(Debug, Default, PartialEq, Eq)]
318pub struct Seeded {
319    pub departments: usize,
320    pub people: usize,
321    pub days: usize,
322}
323
324/// Seeds the team into an empty database.
325///
326/// `now` is the moment the history is built back from: the last eight weeks
327/// end yesterday, and the one open day is open as of this instant. Passed in
328/// rather than read from the clock so a test can pin it.
329///
330/// Refuses a database that holds accounts already - the check is here as
331/// well as in `status`, because this is the function that writes.
332pub async fn seed(pool: &PgPool, now: DateTime<Utc>) -> Result<Seeded> {
333    match status(pool).await? {
334        Status::Empty => {}
335        Status::Demo => bail!("this database already holds the demo team"),
336        Status::Populated { accounts } => bail!("this database already holds {accounts} accounts; the demo only seeds an empty one"),
337    }
338
339    let mut seeded = Seeded::default();
340
341    // People, departments and agents in one transaction, with the demo mark
342    // first: if the process dies between here and the last day written, the
343    // next start sees a demo and starts, rather than seeing accounts it did
344    // not make and refusing.
345    let mut tx = pool.begin().await?;
346    sqlx::query("UPDATE settings SET demo = true WHERE singleton").execute(&mut *tx).await?;
347
348    let mut department_ids = Vec::with_capacity(DEPARTMENTS.len());
349    for department in &DEPARTMENTS {
350        let id: Uuid = sqlx::query_scalar("INSERT INTO departments (name) VALUES ($1) RETURNING id")
351            .bind(department.name)
352            .fetch_one(&mut *tx)
353            .await
354            .with_context(|| format!("failed to create the {} department", department.name))?;
355        department_ids.push((department.name, id));
356        seeded.departments += 1;
357    }
358    let department_id = |name: &str| department_ids.iter().find(|(n, _)| *n == name).map(|(_, id)| *id);
359
360    // One hash for one password: argon2 is deliberately slow, and twelve of
361    // them at startup is a pause a visitor would notice.
362    let password_hash = hash_password(PASSWORD)?;
363
364    let mut user_ids = Vec::with_capacity(TEAM.len());
365    for person in &TEAM {
366        let id: Uuid = sqlx::query_scalar(
367            "INSERT INTO users (email, display_name, role, password_hash, active, department_id)
368             VALUES ($1, $2, $3, $4, true, $5) RETURNING id",
369        )
370        .bind(person.email())
371        .bind(person.display_name())
372        .bind(person.role)
373        .bind(&password_hash)
374        .bind(person.department.and_then(department_id))
375        .fetch_one(&mut *tx)
376        .await
377        .with_context(|| format!("failed to create the account for {}", person.display_name()))?;
378
379        if person.pattern.is_some() {
380            sqlx::query("INSERT INTO agents (user_id, name, token_hash) VALUES ($1, $2, $3)")
381                .bind(id)
382                .bind(format!("{}-laptop", person.first.to_ascii_lowercase()))
383                .bind(hash_token(&person.token()))
384                .execute(&mut *tx)
385                .await
386                .with_context(|| format!("failed to create the agent for {}", person.display_name()))?;
387        }
388
389        user_ids.push(id);
390        seeded.people += 1;
391    }
392
393    for department in &DEPARTMENTS {
394        let manager = TEAM
395            .iter()
396            .position(|person| person.email().starts_with(department.manager))
397            .expect("every department in the table names a member of the team");
398        sqlx::query("UPDATE departments SET manager_id = $1 WHERE name = $2")
399            .bind(user_ids[manager])
400            .bind(department.name)
401            .execute(&mut *tx)
402            .await?;
403    }
404    tx.commit().await?;
405
406    for (index, person) in TEAM.iter().enumerate() {
407        let Some(pattern) = person.pattern else { continue };
408        let days = days_for(person, pattern, index as u64, now);
409        // One commit per person: the same rows an import writes, without the
410        // per-day commit an import needs to survive failing halfway.
411        let mut tx = pool.begin().await?;
412        for day in &days {
413            import::write_day(&mut tx, user_ids[index], day, person.offset()).await?;
414        }
415        tx.commit().await?;
416        seeded.days += days.len();
417
418        // When the server last heard from this machine: the end of the last
419        // day for most, this very moment for the open one, never for the
420        // agent that never reported. The dashboard's "last data 2 d ago" and
421        // "never reported" come from here.
422        let last_seen: Option<DateTime<Utc>> = match pattern {
423            Pattern::Never => None,
424            Pattern::Open => Some(now),
425            _ => days
426                .iter()
427                .filter_map(|day| day.end)
428                .max()
429                .map(|end| import::at_offset(end, person.offset()).with_timezone(&Utc)),
430        };
431        let (pulse, age) = pulse_for(pattern);
432        sqlx::query(
433            "UPDATE agents SET last_seen_at = $2, heartbeat_state = $3, demo_pulse_age_seconds = $4,
434                    heartbeat_at = CASE WHEN $3 IS NULL THEN NULL ELSE now() - coalesce($4, 0) * interval '1 second' END,
435                    heartbeat_received_at = CASE WHEN $3 IS NULL THEN NULL ELSE now() - coalesce($4, 0) * interval '1 second' END
436             WHERE user_id = $1",
437        )
438        .bind(user_ids[index])
439        .bind(last_seen)
440        .bind(pulse)
441        .bind(age)
442        .execute(pool)
443        .await?;
444    }
445
446    audit::Entry::new(audit::action::DEMO_SEEDED)
447        .with(serde_json::json!({ "people": seeded.people, "departments": seeded.departments, "days": seeded.days }))
448        .record(pool)
449        .await;
450
451    Ok(seeded)
452}
453
454/// The pulse a pattern gets, and how old it is kept.
455///
456/// Only the people whose day is happening now get a live one; everyone else
457/// has stopped for the day, which on a real installation is silence, and
458/// silence is what a visitor should see it look like (ADR 0014).
459fn pulse_for(pattern: Pattern) -> (Option<AgentState>, Option<i32>) {
460    let state = match pattern {
461        // Mid-day, at the keyboard - the row that reads "working".
462        Pattern::Open => Some(AgentState::Working),
463        // Also mid-day, but away from it: the demo needs both live states on
464        // screen, or "paused" would never be seen.
465        Pattern::Breaks => Some(AgentState::Paused),
466        // Their agent is up and reporting, they are simply done for the day.
467        // This is what distinguishes `idle` from `offline` - and the reason
468        // the dashboard needs both.
469        Pattern::Steady => Some(AgentState::Idle),
470        // A pulse that is deliberately old: the agent was running this morning
471        // and has stopped answering. That is the row a manager should look at
472        // first, and it is only visible if the demo carries one - "no pulse at
473        // all" reads as `unknown`, which says nothing about the person.
474        Pattern::Long | Pattern::Fading => Some(AgentState::Working),
475        // Silent and Never never sent one, which is `unknown`.
476        _ => None,
477    };
478    // Zero for the live ones; well past the threshold for the two that have
479    // stopped. Recorded on the row rather than recomputed, so the refresh
480    // knows which is which - a pulse that merely aged is indistinguishable
481    // from one seeded old.
482    let age = state.map(|_| {
483        if matches!(pattern, Pattern::Long | Pattern::Fading) {
484            STALE_PULSE_AGE_SECONDS
485        } else {
486            0
487        }
488    });
489    (state, age)
490}
491
492/// Gives the demo's agents their pulses if they have none.
493///
494/// The upgrade path. A demo seeded before this milestone has agents but no
495/// pulses, and bumping the image does not re-seed - so its dashboard would
496/// show twelve rows of "unknown" and none of the live column the version was
497/// released for. Found by deploying to the project's own demo stand, not by a
498/// test: every test seeds from empty, where the question cannot arise.
499///
500/// Idempotent, and it never overwrites a pulse that exists: an agent that has
501/// reported - including a real kasl pointed at the demo - is left alone. The
502/// people are matched by the email the generator assigns, so nothing outside
503/// the fictional team is touched.
504pub async fn ensure_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
505    let mut given = 0;
506    for person in TEAM.iter() {
507        let Some(pattern) = person.pattern else { continue };
508        let (Some(state), age) = pulse_for(pattern) else { continue };
509        let updated = sqlx::query(
510            "UPDATE agents SET heartbeat_state = $2, demo_pulse_age_seconds = $3,
511                    heartbeat_at = now() - coalesce($3, 0) * interval '1 second',
512                    heartbeat_received_at = now() - coalesce($3, 0) * interval '1 second'
513             FROM users u
514             WHERE agents.user_id = u.id AND lower(u.email) = lower($1)
515               AND agents.heartbeat_state IS NULL AND agents.revoked_at IS NULL",
516        )
517        .bind(person.email())
518        .bind(state)
519        .bind(age)
520        .execute(pool)
521        .await?;
522        given += updated.rows_affected();
523    }
524    Ok(given)
525}
526
527/// Re-stamps the demo's seeded pulses so they stay fresh.
528///
529/// The demo is seeded once, but a pulse is believed for three minutes - so
530/// without this every live row on the demo would turn "offline" while the
531/// first visitor was still reading the page, and the milestone would be
532/// invisible on the one installation built to show it off.
533///
534/// Only rows that already carry a state are touched: which people are working,
535/// paused or idle was decided at seed time and stays decided, and an agent
536/// seeded silent must keep looking silent. Returns how many were re-stamped.
537///
538/// Only the pulses that were fresh when they were written are pulled forward.
539/// Two of the demo's agents are seeded deliberately stale - they are the "this
540/// machine has stopped answering" row a manager should look at first - and a
541/// refresh that pulled every stamp up to `now()` would quietly heal them,
542/// leaving the dashboard with no offline row to show. They are held at a fixed
543/// age instead, so they stay offline for as long as the demo runs.
544///
545/// Nothing like this runs on a real installation - there a pulse means an
546/// agent sent one, and a server that invented them would be lying about the
547/// only thing this endpoint is for.
548pub async fn refresh_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
549    // Each row is re-stamped to the age the seed chose for it, read off the
550    // row itself. Only agents the demo gave an age are touched, so an agent a
551    // visitor pointed at the demo keeps whatever pulse it actually sent.
552    let updated = sqlx::query(
553        "UPDATE agents
554         SET heartbeat_at = now() - demo_pulse_age_seconds * interval '1 second',
555             heartbeat_received_at = now() - demo_pulse_age_seconds * interval '1 second'
556         WHERE heartbeat_state IS NOT NULL AND demo_pulse_age_seconds IS NOT NULL AND revoked_at IS NULL",
557    )
558    .execute(pool)
559    .await?;
560    Ok(updated.rows_affected())
561}
562
563/// How old the demo's stopped agents are kept.
564///
565/// Comfortably past the staleness threshold, and stable across refreshes, so
566/// "this machine stopped answering" stays on the dashboard rather than healing
567/// itself a minute after the visitor arrives.
568const STALE_PULSE_AGE_SECONDS: i32 = (heartbeat::STALE_AFTER_SECONDS * 4) as i32;
569
570/// Keeps the demo's pulses fresh for as long as the server runs.
571///
572/// Started only when the installation is a demo. Re-stamps at half the
573/// staleness threshold, so a slow tick cannot let the dashboard flicker
574/// through "offline" between two refreshes.
575pub fn keep_pulses_fresh(pool: PgPool) {
576    let period = std::time::Duration::from_secs((heartbeat::STALE_AFTER_SECONDS / 2).max(1) as u64);
577    tokio::spawn(async move {
578        let mut ticker = tokio::time::interval(period);
579        // The first tick fires immediately and would re-stamp what `seed` just
580        // wrote; skipping it costs nothing and keeps the log quiet.
581        ticker.tick().await;
582        loop {
583            ticker.tick().await;
584            if let Err(error) = refresh_pulses(&pool).await {
585                // Worth a line, not worth stopping for: the dashboard degrades
586                // to "offline", which is at least an honest reading of a
587                // server that cannot reach its database.
588                tracing::warn!(%error, "failed to refresh the demo pulses");
589            }
590        }
591    });
592}
593
594/// Eight weeks of one person's days, ending yesterday.
595///
596/// Deterministic: the same person on the same date produces the same days.
597/// `index` seeds the generator so people do not share a stream and reordering
598/// the team table does not reshape everyone's history.
599fn days_for(person: &Person, pattern: Pattern, index: u64, now: DateTime<Utc>) -> Vec<AgentDay> {
600    if pattern == Pattern::Never {
601        return Vec::new();
602    }
603
604    let mut rng = Rng::new(index);
605    let now_local = now.with_timezone(&person.offset()).naive_local();
606    let today = now_local.date();
607    let first = today - Duration::weeks(WEEKS);
608    let pool = task_pool(person.department.unwrap_or("Support"));
609
610    // Tasks not yet finished, carried into the next day the way kasl carries
611    // them: same group id, higher completeness.
612    let mut carried: Vec<(i32, &'static str, i16)> = Vec::new();
613    let mut next_task_id = 1;
614    let mut days = Vec::new();
615
616    for offset in 0..(today - first).num_days() {
617        let date = first + Duration::days(offset);
618
619        if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
620            continue;
621        }
622        // A sick day now and then: the dashboard should show a gap.
623        if rng.chance(4) {
624            continue;
625        }
626        if pattern == Pattern::Silent && date >= today - Duration::days(7) {
627            continue;
628        }
629
630        let week = ((date - first).num_days() / 7) as f64;
631        let (start_minute, span_minutes) = match pattern {
632            Pattern::Long => (rng.range(8 * 60, 8 * 60 + 30), rng.range(10 * 60, 11 * 60 + 15)),
633            Pattern::Fading => (rng.range(9 * 60, 9 * 60 + 45), (8.5 * 60.0 - week * 0.45 * 60.0) as i64 + rng.range(-15, 15)),
634            _ => (rng.range(8 * 60 + 45, 9 * 60 + 30), rng.range(7 * 60 + 45, 8 * 60 + 45)),
635        };
636        let start = date.and_time(minutes(start_minute));
637        let end = start + Duration::minutes(span_minutes);
638
639        let pauses = pauses_for(&mut rng, pattern, start, span_minutes);
640        let tasks = tasks_for(&mut rng, pool, &mut carried, &mut next_task_id, end);
641
642        days.push(AgentDay {
643            date,
644            start,
645            end: Some(end),
646            pauses,
647            tasks,
648        });
649    }
650
651    if pattern == Pattern::Open {
652        // Started three hours ago, or at midnight if the day is younger than
653        // that: an open day's start cannot lie on yesterday's date, which has
654        // its own row.
655        let start = (now_local - Duration::hours(3)).max(today.and_time(NaiveTime::MIN));
656        let elapsed = (now_local - start).num_minutes();
657        let mut pauses = Vec::new();
658        if elapsed > 90 {
659            let pause_start = start + Duration::minutes(elapsed / 2);
660            pauses.push(AgentPause {
661                start: pause_start,
662                end: Some(pause_start + Duration::minutes(11)),
663                duration_seconds: Some(11 * 60),
664                manual: false,
665                reason: None,
666            });
667        }
668        let name = carried.first().map(|(_, name, _)| *name).unwrap_or(pool[0]);
669        let group = carried.first().map(|(group, _, _)| *group).unwrap_or(next_task_id);
670        days.push(AgentDay {
671            date: today,
672            start,
673            end: None,
674            pauses,
675            tasks: vec![AgentTask {
676                agent_task_id: next_task_id,
677                agent_group_id: group,
678                recorded_at: now_local - Duration::minutes(elapsed.min(20)),
679                name: name.to_string(),
680                comment: None,
681                completeness: 40,
682            }],
683        });
684    }
685
686    days
687}
688
689/// The interruptions of one day.
690///
691/// Placed on hourly slots so they never overlap: each starts within the first
692/// half hour of its slot and lasts under half an hour. Lunch sits on the
693/// fourth hour for everyone; whether it is a detected absence or a break the
694/// person entered depends on the pattern.
695fn pauses_for(rng: &mut Rng, pattern: Pattern, start: NaiveDateTime, span_minutes: i64) -> Vec<AgentPause> {
696    let manual = pattern == Pattern::Breaks;
697    let hours = span_minutes / 60;
698    let mut pauses = Vec::new();
699
700    let lunch = start + Duration::hours(4) + Duration::minutes(rng.range(0, 15));
701    pauses.push(pause(lunch, rng.range(28, 40), manual, manual.then_some("Lunch")));
702
703    let idle_count = match pattern {
704        Pattern::Long => rng.range(1, 2),
705        Pattern::Breaks => rng.range(1, 3),
706        _ => rng.range(2, 3),
707    };
708    let mut slots: Vec<i64> = (1..hours).filter(|slot| *slot != 4).collect();
709    for _ in 0..idle_count {
710        if slots.is_empty() {
711            break;
712        }
713        let slot = slots.remove(rng.range(0, slots.len() as i64 - 1) as usize);
714        let at = start + Duration::hours(slot) + Duration::minutes(rng.range(0, 30));
715        let reason = manual.then(|| *rng.pick(&BREAK_REASONS[1..]));
716        pauses.push(pause(at, rng.range(5, 25), manual, reason));
717    }
718
719    pauses.sort_by_key(|pause| pause.start);
720    pauses
721}
722
723fn pause(start: NaiveDateTime, minutes: i64, manual: bool, reason: Option<&str>) -> AgentPause {
724    AgentPause {
725        start,
726        end: Some(start + Duration::minutes(minutes)),
727        duration_seconds: Some((minutes * 60) as i32),
728        manual,
729        reason: reason.map(str::to_string),
730    }
731}
732
733/// The tasks logged at the end of one day.
734///
735/// About half the time a task continues from the day before, so the history
736/// shows work carried across days the way kasl records it; the rest are new.
737/// Unfinished ones are carried forward, two at most.
738fn tasks_for(rng: &mut Rng, pool: &[&'static str], carried: &mut Vec<(i32, &'static str, i16)>, next_id: &mut i32, end: NaiveDateTime) -> Vec<AgentTask> {
739    let count = rng.range(2, 4);
740    let mut tasks = Vec::new();
741    let mut still_open = Vec::new();
742
743    for _ in 0..count {
744        let continued = !carried.is_empty() && rng.chance(55);
745        let (group, name, completeness) = if continued {
746            let (group, name, done) = carried.remove(0);
747            (group, name, (done + rng.range(20, 50) as i16).min(100))
748        } else {
749            let name = *rng.pick(pool);
750            (*next_id, name, [20i16, 40, 60, 80, 100][rng.range(0, 4) as usize])
751        };
752        let id = *next_id;
753        *next_id += 1;
754
755        tasks.push(AgentTask {
756            agent_task_id: id,
757            agent_group_id: group,
758            recorded_at: end - Duration::minutes(rng.range(2, 15)),
759            name: name.to_string(),
760            comment: None,
761            completeness,
762        });
763        if completeness < 100 && still_open.len() < 2 {
764            still_open.push((group, name, completeness));
765        }
766    }
767
768    *carried = still_open;
769    tasks
770}
771
772fn minutes(since_midnight: i64) -> NaiveTime {
773    NaiveTime::from_num_seconds_from_midnight_opt((since_midnight * 60) as u32, 0).expect("a minute of the day")
774}
775
776/// A small deterministic generator (xorshift64*).
777///
778/// Not `rand`: its output for a given seed is not promised to stay the same
779/// across versions, and the point of seeding is that the demo looks the same
780/// after a dependency update as before it.
781struct Rng(u64);
782
783impl Rng {
784    fn new(seed: u64) -> Self {
785        // Mixed so that neighbouring seeds do not produce neighbouring streams;
786        // the `| 1` keeps zero - the one state xorshift never leaves - out.
787        Self((seed + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
788    }
789
790    fn next(&mut self) -> u64 {
791        let mut x = self.0;
792        x ^= x >> 12;
793        x ^= x << 25;
794        x ^= x >> 27;
795        self.0 = x;
796        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
797    }
798
799    /// A value in `low..=high`.
800    fn range(&mut self, low: i64, high: i64) -> i64 {
801        debug_assert!(low <= high);
802        low + (self.next() % (high - low + 1) as u64) as i64
803    }
804
805    fn chance(&mut self, percent: u64) -> bool {
806        self.next() % 100 < percent
807    }
808
809    fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
810        &items[self.range(0, items.len() as i64 - 1) as usize]
811    }
812}
813
814/// What `GET /api/v1/demo/accounts` answers on a demo.
815#[derive(Debug, Serialize)]
816struct Accounts {
817    password: &'static str,
818    accounts: Vec<Account>,
819}
820
821/// The accounts a visitor may sign in as, and their password.
822///
823/// Unauthenticated on purpose - it is what the login screen shows before
824/// anyone is signed in - and answered only where the database says this is a
825/// demo, so a real installation with a real team never lists anybody here.
826pub async fn accounts(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
827    let demo: bool = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton").fetch_one(&state.pool).await?;
828    if !demo {
829        return Err(ApiError::new(StatusCode::NOT_FOUND, "this server is not a demo"));
830    }
831    Ok(Json(Accounts {
832        password: PASSWORD,
833        accounts: showcase(),
834    }))
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    fn at(date: &str, time: &str) -> DateTime<Utc> {
842        format!("{date}T{time}Z").parse().expect("a test timestamp")
843    }
844
845    fn person(pattern: Pattern) -> &'static Person {
846        TEAM.iter().find(|person| person.pattern == Some(pattern)).expect("every pattern has a person")
847    }
848
849    #[test]
850    fn every_department_names_a_manager_who_is_in_the_team() {
851        for department in &DEPARTMENTS {
852            let manager = TEAM.iter().find(|person| person.email().starts_with(department.manager));
853            let manager = manager.unwrap_or_else(|| panic!("{} names nobody in the team", department.name));
854            assert_eq!(manager.role, UserRole::Manager, "{} is run by a {:?}", department.name, manager.role);
855            assert_eq!(manager.department, Some(department.name), "a manager belongs to the department they run");
856        }
857    }
858
859    #[test]
860    fn the_team_has_one_of_every_role_and_every_pattern() {
861        for role in [UserRole::Admin, UserRole::Manager, UserRole::Employee] {
862            assert!(TEAM.iter().any(|person| person.role == role), "nobody is a {role:?}");
863        }
864        for pattern in [
865            Pattern::Steady,
866            Pattern::Long,
867            Pattern::Fading,
868            Pattern::Breaks,
869            Pattern::Open,
870            Pattern::Silent,
871            Pattern::Never,
872        ] {
873            assert!(TEAM.iter().any(|person| person.pattern == Some(pattern)), "nobody is {pattern:?}");
874        }
875        assert_eq!(showcase().len(), 3, "one account of each role to try");
876        assert_eq!(showcase()[0].role, UserRole::Manager, "the manager's dashboard is what the demo is for");
877    }
878
879    #[test]
880    fn emails_are_under_a_reserved_domain() {
881        // The names are invented; the domain guarantees the addresses are too.
882        for person in &TEAM {
883            assert!(person.email().ends_with("@example.com"), "{}", person.email());
884        }
885        let mut emails: Vec<String> = TEAM.iter().map(Person::email).collect();
886        emails.dedup();
887        assert_eq!(emails.len(), TEAM.len(), "two people share an address");
888    }
889
890    #[test]
891    fn the_history_is_the_same_for_the_same_today() {
892        // A screenshot taken from one demo must be reproducible on another
893        // started the same day.
894        let now = at("2026-08-29", "10:00:00");
895        let person = person(Pattern::Steady);
896        let one = days_for(person, Pattern::Steady, 4, now);
897        let two = days_for(person, Pattern::Steady, 4, now);
898        assert_eq!(one.len(), two.len());
899        for (a, b) in one.iter().zip(&two) {
900            assert_eq!(
901                (a.date, a.start, a.end, a.pauses.len(), a.tasks.len()),
902                (b.date, b.start, b.end, b.pauses.len(), b.tasks.len())
903            );
904        }
905        assert!(
906            one.len() >= 35,
907            "eight weeks of weekdays, minus the odd sick day, is at least 35 days; got {}",
908            one.len()
909        );
910    }
911
912    #[test]
913    fn days_end_yesterday_and_skip_weekends() {
914        let now = at("2026-08-29", "10:00:00");
915        let person = person(Pattern::Steady);
916        let days = days_for(person, Pattern::Steady, 4, now);
917        let today = now.with_timezone(&person.offset()).date_naive();
918        for day in &days {
919            assert!(day.date < today, "{} is not in the past", day.date);
920            assert!(!matches!(day.date.weekday(), Weekday::Sat | Weekday::Sun), "{} is a weekend", day.date);
921            assert!(day.end.is_some(), "every past day is closed");
922            let end = day.end.unwrap();
923            for pause in &day.pauses {
924                assert!(pause.start > day.start && pause.end.unwrap() < end, "a pause lies inside its day");
925            }
926            for pair in day.pauses.windows(2) {
927                assert!(pair[0].end.unwrap() <= pair[1].start, "pauses do not overlap on {}", day.date);
928            }
929        }
930    }
931
932    #[test]
933    fn the_open_day_is_today_and_still_running() {
934        let now = at("2026-08-29", "14:00:00");
935        let person = person(Pattern::Open);
936        let days = days_for(person, Pattern::Open, 7, now);
937        let today = now.with_timezone(&person.offset()).date_naive();
938        let open = days.iter().find(|day| day.end.is_none()).expect("one day is open");
939        assert_eq!(open.date, today);
940        assert!(open.start <= now.with_timezone(&person.offset()).naive_local());
941        assert_eq!(days.iter().filter(|day| day.end.is_none()).count(), 1);
942    }
943
944    #[test]
945    fn an_open_day_started_after_midnight_does_not_reach_into_yesterday() {
946        // 01:00 local: three hours ago is yesterday, which has its own row.
947        let person = person(Pattern::Open);
948        let now = at("2026-08-29", "04:00:00"); // 01:00 at UTC-3
949        let days = days_for(person, Pattern::Open, 7, now);
950        let open = days.iter().find(|day| day.end.is_none()).unwrap();
951        assert_eq!(open.start, open.date.and_time(NaiveTime::MIN));
952    }
953
954    #[test]
955    fn the_silent_agent_stops_a_week_before_today() {
956        let now = at("2026-08-29", "10:00:00");
957        let person = person(Pattern::Silent);
958        let days = days_for(person, Pattern::Silent, 9, now);
959        let today = now.with_timezone(&person.offset()).date_naive();
960        assert!(!days.is_empty());
961        assert!(days.iter().all(|day| day.date < today - Duration::days(7)), "nothing in the last week");
962    }
963
964    #[test]
965    fn the_fading_hours_actually_fade() {
966        let now = at("2026-08-29", "10:00:00");
967        let person = person(Pattern::Fading);
968        let days = days_for(person, Pattern::Fading, 6, now);
969        let span = |day: &AgentDay| (day.end.unwrap() - day.start).num_minutes();
970        let first_week: Vec<i64> = days.iter().take(5).map(span).collect();
971        let last_week: Vec<i64> = days.iter().rev().take(5).map(span).collect();
972        let average = |week: &[i64]| week.iter().sum::<i64>() / week.len() as i64;
973        assert!(
974            average(&first_week) - average(&last_week) > 120,
975            "the last week should be hours shorter than the first: {first_week:?} vs {last_week:?}"
976        );
977    }
978
979    #[test]
980    fn tasks_carry_across_days_under_one_group() {
981        let now = at("2026-08-29", "10:00:00");
982        let person = person(Pattern::Steady);
983        let days = days_for(person, Pattern::Steady, 4, now);
984        let mut ids = std::collections::HashSet::new();
985        let mut carried = 0;
986        for day in &days {
987            for task in &day.tasks {
988                assert!(ids.insert(task.agent_task_id), "agent_task_id {} repeats", task.agent_task_id);
989                if task.agent_group_id != task.agent_task_id {
990                    carried += 1;
991                }
992            }
993        }
994        assert!(carried > 0, "some work should span more than one day");
995    }
996
997    #[test]
998    fn the_never_pattern_has_no_days() {
999        let person = person(Pattern::Never);
1000        assert!(days_for(person, Pattern::Never, 11, at("2026-08-29", "10:00:00")).is_empty());
1001    }
1002
1003    #[test]
1004    fn the_generator_stays_in_range() {
1005        let mut rng = Rng::new(3);
1006        for _ in 0..1000 {
1007            let value = rng.range(5, 7);
1008            assert!((5..=7).contains(&value));
1009        }
1010        let mut heads = 0;
1011        for _ in 0..1000 {
1012            if rng.chance(50) {
1013                heads += 1;
1014            }
1015        }
1016        assert!((350..=650).contains(&heads), "a 50% chance came up {heads} times in 1000");
1017    }
1018}