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