1use 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
44pub const PASSWORD: &str = "kasl-demo";
50
51const DOMAIN: &str = "example.com";
54
55const WEEKS: i64 = 8;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum Pattern {
61 Steady,
63 Long,
72 Fading,
75 Breaks,
77 PartTime,
82 Open,
84 Stranded,
92 Silent,
94 Never,
96}
97
98impl Pattern {
99 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 #[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
136struct Person {
138 first: &'static str,
139 last: &'static str,
140 role: UserRole,
141 department: Option<&'static str>,
144 offset_minutes: i32,
147 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 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
171struct 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
192const 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
292fn 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
324const BREAK_REASONS: [&str; 5] = ["Lunch", "School run", "Dentist", "Walk", "Errand"];
326
327#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
329pub struct Account {
330 pub role: UserRole,
331 pub email: String,
332 pub display_name: String,
333}
334
335pub 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#[derive(Debug, PartialEq, Eq)]
351pub enum Status {
352 Empty,
354 Demo,
356 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#[derive(Debug, Default, PartialEq, Eq)]
377pub struct Seeded {
378 pub departments: usize,
379 pub people: usize,
380 pub days: usize,
381 pub calendar_days: usize,
383}
384
385const PART_TIME: [(&str, &str); 1] = [("mira.halvorsen", "0.6")];
396
397const 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
408pub 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 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 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 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 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 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 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
569fn 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
589fn pulse_for(pattern: Pattern) -> (Option<AgentState>, Option<i32>) {
595 let state = match pattern {
596 Pattern::Open => Some(AgentState::Working),
598 Pattern::Breaks => Some(AgentState::Paused),
601 Pattern::Steady => Some(AgentState::Idle),
605 Pattern::Stranded => Some(AgentState::Idle),
612 Pattern::Long | Pattern::Fading => Some(AgentState::Working),
617 _ => None,
619 };
620 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
634pub 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
669pub async fn ensure_calendar(pool: &PgPool, now: DateTime<Utc>) -> Result<u64, sqlx::Error> {
681 let existing: i64 = sqlx::query_scalar("SELECT count(*) FROM calendar_days").fetch_one(pool).await?;
682 let mut written = 0;
683
684 if existing == 0 {
685 let today = now.date_naive();
686 for (weeks_ago, kind, note) in HOLIDAY_WEEKS_AGO {
687 let date = weekday_near(today - Duration::weeks(weeks_ago), kind);
688 let inserted = sqlx::query("INSERT INTO calendar_days (date, kind, note) VALUES ($1, $2, $3) ON CONFLICT (date) DO NOTHING")
689 .bind(date)
690 .bind(kind)
691 .bind(note)
692 .execute(pool)
693 .await?;
694 written += inserted.rows_affected();
695 }
696 }
697
698 for (email_prefix, rate) in PART_TIME {
699 let updated = sqlx::query("UPDATE users SET work_rate = $1::numeric WHERE email LIKE $2 AND work_rate = 1")
702 .bind(rate)
703 .bind(format!("{email_prefix}@%"))
704 .execute(pool)
705 .await?;
706 written += updated.rows_affected();
707 }
708
709 Ok(written)
710}
711
712pub async fn refresh_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
734 let updated = sqlx::query(
738 "UPDATE agents
739 SET heartbeat_at = now() - demo_pulse_age_seconds * interval '1 second',
740 heartbeat_received_at = now() - demo_pulse_age_seconds * interval '1 second'
741 WHERE heartbeat_state IS NOT NULL AND demo_pulse_age_seconds IS NOT NULL AND revoked_at IS NULL",
742 )
743 .execute(pool)
744 .await?;
745 Ok(updated.rows_affected())
746}
747
748const STALE_PULSE_AGE_SECONDS: i32 = (heartbeat::STALE_AFTER_SECONDS * 4) as i32;
754
755pub fn keep_pulses_fresh(pool: PgPool) {
761 let period = std::time::Duration::from_secs((heartbeat::STALE_AFTER_SECONDS / 2).max(1) as u64);
762 tokio::spawn(async move {
763 let mut ticker = tokio::time::interval(period);
764 ticker.tick().await;
767 loop {
768 ticker.tick().await;
769 if let Err(error) = refresh_pulses(&pool).await {
770 tracing::warn!(%error, "failed to refresh the demo pulses");
774 }
775 }
776 });
777}
778
779fn days_for(person: &Person, pattern: Pattern, index: u64, now: DateTime<Utc>) -> Vec<AgentDay> {
785 if pattern == Pattern::Never {
786 return Vec::new();
787 }
788
789 let mut rng = Rng::new(index);
790 let now_local = now.with_timezone(&person.offset()).naive_local();
791 let today = now_local.date();
792 let first = today - Duration::weeks(WEEKS);
793 let pool = task_pool(person.department.unwrap_or("Support"));
794
795 let mut carried: Vec<(i32, &'static str, i16)> = Vec::new();
798 let mut next_task_id = 1;
799 let mut days = Vec::new();
800
801 for offset in 0..(today - first).num_days() {
802 let date = first + Duration::days(offset);
803
804 if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
805 continue;
806 }
807 if pattern == Pattern::Silent && date >= today - Duration::days(7) {
808 continue;
809 }
810
811 if rng.chance(8) {
821 if rng.chance(60) {
822 days.push(AgentDay {
823 date,
824 start: date.and_time(minutes(9 * 60)),
828 end: Some(date.and_time(minutes(9 * 60))),
829 pauses: Vec::new(),
830 tasks: Vec::new(),
831 kind: WorkdayKind::Sick,
832 });
833 }
834 continue;
835 }
836
837 let week = ((date - first).num_days() / 7) as f64;
838 let (start_minute, span_minutes) = match pattern {
839 Pattern::Long => (rng.range(8 * 60, 8 * 60 + 30), rng.range(10 * 60, 13 * 60 + 30)),
845 Pattern::Fading => (rng.range(9 * 60, 9 * 60 + 45), (8.5 * 60.0 - week * 0.45 * 60.0) as i64 + rng.range(-15, 15)),
846 Pattern::PartTime => (rng.range(10 * 60, 10 * 60 + 30), rng.range(4 * 60 + 40, 5 * 60 + 10)),
849 _ => (rng.range(8 * 60 + 45, 9 * 60 + 30), rng.range(7 * 60 + 45, 8 * 60 + 45)),
850 };
851 let start = date.and_time(minutes(start_minute));
852 let end = start + Duration::minutes(span_minutes);
853
854 let pauses = pauses_for(&mut rng, pattern, start, span_minutes);
855 let tasks = tasks_for(&mut rng, pool, &mut carried, &mut next_task_id, end);
856
857 days.push(AgentDay {
858 date,
859 start,
860 end: Some(end),
861 pauses,
862 tasks,
863 kind: WorkdayKind::Work,
864 });
865 }
866
867 if pattern == Pattern::Stranded {
868 let stranded_date = today - Duration::days(1);
876 let salvaged = days.iter().position(|day| day.date == stranded_date).map(|at| days.remove(at));
882 let (start, pauses, tasks) = match salvaged {
883 Some(day) => (
884 day.start,
885 day.pauses.into_iter().filter(|pause| pause.end.is_some()).collect(),
889 day.tasks,
890 ),
891 None => (stranded_date.and_time(minutes(8 * 60 + 40)), Vec::new(), Vec::new()),
892 };
893 days.push(AgentDay {
894 date: stranded_date,
895 start,
896 end: None,
897 pauses,
898 kind: WorkdayKind::Work,
899 tasks,
900 });
901 }
902
903 if pattern == Pattern::Open {
904 let start = (now_local - Duration::hours(3)).max(today.and_time(NaiveTime::MIN));
908 let elapsed = (now_local - start).num_minutes();
909 let mut pauses = Vec::new();
910 if elapsed > 90 {
911 let pause_start = start + Duration::minutes(elapsed / 2);
912 pauses.push(AgentPause {
913 start: pause_start,
914 end: Some(pause_start + Duration::minutes(11)),
915 duration_seconds: Some(11 * 60),
916 manual: false,
917 reason: None,
918 });
919 }
920 let name = carried.first().map(|(_, name, _)| *name).unwrap_or(pool[0]);
921 let group = carried.first().map(|(group, _, _)| *group).unwrap_or(next_task_id);
922 days.push(AgentDay {
923 date: today,
924 start,
925 end: None,
926 pauses,
927 kind: WorkdayKind::Work,
928 tasks: vec![AgentTask {
929 agent_task_id: next_task_id,
930 agent_group_id: group,
931 recorded_at: now_local - Duration::minutes(elapsed.min(20)),
932 name: name.to_string(),
933 comment: None,
934 completeness: 40,
935 }],
936 });
937 }
938
939 days
940}
941
942fn pauses_for(rng: &mut Rng, pattern: Pattern, start: NaiveDateTime, span_minutes: i64) -> Vec<AgentPause> {
949 let manual = pattern == Pattern::Breaks;
950 let hours = span_minutes / 60;
951 let mut pauses = Vec::new();
952
953 let lunch_hour = (hours / 2).clamp(1, 4);
958 let lunch = start + Duration::hours(lunch_hour) + Duration::minutes(rng.range(0, 15));
959 pauses.push(pause(lunch, rng.range(28, 40), manual, manual.then_some("Lunch")));
960
961 let idle_count = match pattern {
962 Pattern::Long => rng.range(1, 2),
963 Pattern::Breaks => rng.range(1, 3),
964 _ => rng.range(2, 3),
965 };
966 let mut slots: Vec<i64> = (1..hours).filter(|slot| *slot != lunch_hour).collect();
967 for _ in 0..idle_count {
968 if slots.is_empty() {
969 break;
970 }
971 let slot = slots.remove(rng.range(0, slots.len() as i64 - 1) as usize);
972 let at = start + Duration::hours(slot) + Duration::minutes(rng.range(0, 30));
973 let reason = manual.then(|| *rng.pick(&BREAK_REASONS[1..]));
974 pauses.push(pause(at, rng.range(5, 25), manual, reason));
975 }
976
977 pauses.sort_by_key(|pause| pause.start);
978 pauses
979}
980
981fn pause(start: NaiveDateTime, minutes: i64, manual: bool, reason: Option<&str>) -> AgentPause {
982 AgentPause {
983 start,
984 end: Some(start + Duration::minutes(minutes)),
985 duration_seconds: Some((minutes * 60) as i32),
986 manual,
987 reason: reason.map(str::to_string),
988 }
989}
990
991fn tasks_for(rng: &mut Rng, pool: &[&'static str], carried: &mut Vec<(i32, &'static str, i16)>, next_id: &mut i32, end: NaiveDateTime) -> Vec<AgentTask> {
997 let count = rng.range(2, 4);
998 let mut tasks = Vec::new();
999 let mut still_open = Vec::new();
1000
1001 for _ in 0..count {
1002 let continued = !carried.is_empty() && rng.chance(55);
1003 let (group, name, completeness) = if continued {
1004 let (group, name, done) = carried.remove(0);
1005 (group, name, (done + rng.range(20, 50) as i16).min(100))
1006 } else {
1007 let name = *rng.pick(pool);
1008 (*next_id, name, [20i16, 40, 60, 80, 100][rng.range(0, 4) as usize])
1009 };
1010 let id = *next_id;
1011 *next_id += 1;
1012
1013 tasks.push(AgentTask {
1014 agent_task_id: id,
1015 agent_group_id: group,
1016 recorded_at: end - Duration::minutes(rng.range(2, 15)),
1017 name: name.to_string(),
1018 comment: None,
1019 completeness,
1020 });
1021 if completeness < 100 && still_open.len() < 2 {
1022 still_open.push((group, name, completeness));
1023 }
1024 }
1025
1026 *carried = still_open;
1027 tasks
1028}
1029
1030fn minutes(since_midnight: i64) -> NaiveTime {
1031 NaiveTime::from_num_seconds_from_midnight_opt((since_midnight * 60) as u32, 0).expect("a minute of the day")
1032}
1033
1034struct Rng(u64);
1040
1041impl Rng {
1042 fn new(seed: u64) -> Self {
1043 Self((seed + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
1046 }
1047
1048 fn next(&mut self) -> u64 {
1049 let mut x = self.0;
1050 x ^= x >> 12;
1051 x ^= x << 25;
1052 x ^= x >> 27;
1053 self.0 = x;
1054 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
1055 }
1056
1057 fn range(&mut self, low: i64, high: i64) -> i64 {
1059 debug_assert!(low <= high);
1060 low + (self.next() % (high - low + 1) as u64) as i64
1061 }
1062
1063 fn chance(&mut self, percent: u64) -> bool {
1064 self.next() % 100 < percent
1065 }
1066
1067 fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
1068 &items[self.range(0, items.len() as i64 - 1) as usize]
1069 }
1070}
1071
1072#[derive(Debug, Serialize)]
1074struct Accounts {
1075 password: &'static str,
1076 accounts: Vec<Account>,
1077}
1078
1079pub async fn accounts(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
1085 let demo: bool = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton").fetch_one(&state.pool).await?;
1086 if !demo {
1087 return Err(ApiError::new(StatusCode::NOT_FOUND, "this server is not a demo"));
1088 }
1089 Ok(Json(Accounts {
1090 password: PASSWORD,
1091 accounts: showcase(),
1092 }))
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098
1099 fn at(date: &str, time: &str) -> DateTime<Utc> {
1100 format!("{date}T{time}Z").parse().expect("a test timestamp")
1101 }
1102
1103 fn person(pattern: Pattern) -> &'static Person {
1104 TEAM.iter().find(|person| person.pattern == Some(pattern)).expect("every pattern has a person")
1105 }
1106
1107 #[test]
1108 fn every_department_names_a_manager_who_is_in_the_team() {
1109 for department in &DEPARTMENTS {
1110 let manager = TEAM.iter().find(|person| person.email().starts_with(department.manager));
1111 let manager = manager.unwrap_or_else(|| panic!("{} names nobody in the team", department.name));
1112 assert_eq!(manager.role, UserRole::Manager, "{} is run by a {:?}", department.name, manager.role);
1113 assert_eq!(manager.department, Some(department.name), "a manager belongs to the department they run");
1114 }
1115 }
1116
1117 #[test]
1118 fn the_team_has_one_of_every_role_and_every_pattern() {
1119 for role in [UserRole::Admin, UserRole::Manager, UserRole::Employee] {
1120 assert!(TEAM.iter().any(|person| person.role == role), "nobody is a {role:?}");
1121 }
1122 for pattern in Pattern::ALL {
1129 assert!(TEAM.iter().any(|person| person.pattern == Some(pattern)), "nobody is {pattern:?}");
1130 }
1131 assert_eq!(showcase().len(), 3, "one account of each role to try");
1132 assert_eq!(showcase()[0].role, UserRole::Manager, "the manager's dashboard is what the demo is for");
1133 }
1134
1135 #[test]
1136 fn emails_are_under_a_reserved_domain() {
1137 for person in &TEAM {
1139 assert!(person.email().ends_with("@example.com"), "{}", person.email());
1140 }
1141 let mut emails: Vec<String> = TEAM.iter().map(Person::email).collect();
1142 emails.dedup();
1143 assert_eq!(emails.len(), TEAM.len(), "two people share an address");
1144 }
1145
1146 #[test]
1147 fn the_history_is_the_same_for_the_same_today() {
1148 let now = at("2026-08-29", "10:00:00");
1151 let person = person(Pattern::Steady);
1152 let one = days_for(person, Pattern::Steady, 4, now);
1153 let two = days_for(person, Pattern::Steady, 4, now);
1154 assert_eq!(one.len(), two.len());
1155 for (a, b) in one.iter().zip(&two) {
1156 assert_eq!(
1157 (a.date, a.start, a.end, a.pauses.len(), a.tasks.len()),
1158 (b.date, b.start, b.end, b.pauses.len(), b.tasks.len())
1159 );
1160 }
1161 assert!(
1162 one.len() >= 35,
1163 "eight weeks of weekdays, minus the odd sick day, is at least 35 days; got {}",
1164 one.len()
1165 );
1166 }
1167
1168 #[test]
1169 fn days_end_yesterday_and_skip_weekends() {
1170 let now = at("2026-08-29", "10:00:00");
1171 let person = person(Pattern::Steady);
1172 let days = days_for(person, Pattern::Steady, 4, now);
1173 let today = now.with_timezone(&person.offset()).date_naive();
1174 for day in &days {
1175 assert!(day.date < today, "{} is not in the past", day.date);
1176 assert!(!matches!(day.date.weekday(), Weekday::Sat | Weekday::Sun), "{} is a weekend", day.date);
1177 assert!(day.end.is_some(), "every past day is closed");
1178 let end = day.end.unwrap();
1179 for pause in &day.pauses {
1180 assert!(pause.start > day.start && pause.end.unwrap() < end, "a pause lies inside its day");
1181 }
1182 for pair in day.pauses.windows(2) {
1183 assert!(pair[0].end.unwrap() <= pair[1].start, "pauses do not overlap on {}", day.date);
1184 }
1185 }
1186 }
1187
1188 #[test]
1189 fn the_open_day_is_today_and_still_running() {
1190 let now = at("2026-08-29", "14:00:00");
1191 let person = person(Pattern::Open);
1192 let days = days_for(person, Pattern::Open, 7, now);
1193 let today = now.with_timezone(&person.offset()).date_naive();
1194 let open = days.iter().find(|day| day.end.is_none()).expect("one day is open");
1195 assert_eq!(open.date, today);
1196 assert!(open.start <= now.with_timezone(&person.offset()).naive_local());
1197 assert_eq!(days.iter().filter(|day| day.end.is_none()).count(), 1);
1198 }
1199
1200 #[test]
1201 fn an_open_day_started_after_midnight_does_not_reach_into_yesterday() {
1202 let person = person(Pattern::Open);
1204 let now = at("2026-08-29", "04:00:00"); let days = days_for(person, Pattern::Open, 7, now);
1206 let open = days.iter().find(|day| day.end.is_none()).unwrap();
1207 assert_eq!(open.start, open.date.and_time(NaiveTime::MIN));
1208 }
1209
1210 #[test]
1211 fn the_silent_agent_stops_a_week_before_today() {
1212 let now = at("2026-08-29", "10:00:00");
1213 let person = person(Pattern::Silent);
1214 let days = days_for(person, Pattern::Silent, 9, now);
1215 let today = now.with_timezone(&person.offset()).date_naive();
1216 assert!(!days.is_empty());
1217 assert!(days.iter().all(|day| day.date < today - Duration::days(7)), "nothing in the last week");
1218 }
1219
1220 #[test]
1221 fn the_fading_hours_actually_fade() {
1222 let now = at("2026-08-29", "10:00:00");
1223 let person = person(Pattern::Fading);
1224 let days = days_for(person, Pattern::Fading, 6, now);
1225 let worked: Vec<&AgentDay> = days.iter().filter(|day| day.kind == WorkdayKind::Work).collect();
1230 let span = |day: &&AgentDay| (day.end.unwrap() - day.start).num_minutes();
1231 let first_week: Vec<i64> = worked.iter().take(5).map(span).collect();
1232 let last_week: Vec<i64> = worked.iter().rev().take(5).map(span).collect();
1233 let average = |week: &[i64]| week.iter().sum::<i64>() / week.len() as i64;
1234 assert!(
1235 average(&first_week) - average(&last_week) > 120,
1236 "the last week should be hours shorter than the first: {first_week:?} vs {last_week:?}"
1237 );
1238 }
1239
1240 #[test]
1241 fn tasks_carry_across_days_under_one_group() {
1242 let now = at("2026-08-29", "10:00:00");
1243 let person = person(Pattern::Steady);
1244 let days = days_for(person, Pattern::Steady, 4, now);
1245 let mut ids = std::collections::HashSet::new();
1246 let mut carried = 0;
1247 for day in &days {
1248 for task in &day.tasks {
1249 assert!(ids.insert(task.agent_task_id), "agent_task_id {} repeats", task.agent_task_id);
1250 if task.agent_group_id != task.agent_task_id {
1251 carried += 1;
1252 }
1253 }
1254 }
1255 assert!(carried > 0, "some work should span more than one day");
1256 }
1257
1258 #[test]
1259 fn the_never_pattern_has_no_days() {
1260 let person = person(Pattern::Never);
1261 assert!(days_for(person, Pattern::Never, 11, at("2026-08-29", "10:00:00")).is_empty());
1262 }
1263
1264 #[test]
1265 fn the_generator_stays_in_range() {
1266 let mut rng = Rng::new(3);
1267 for _ in 0..1000 {
1268 let value = rng.range(5, 7);
1269 assert!((5..=7).contains(&value));
1270 }
1271 let mut heads = 0;
1272 for _ in 0..1000 {
1273 if rng.chance(50) {
1274 heads += 1;
1275 }
1276 }
1277 assert!((350..=650).contains(&heads), "a 50% chance came up {heads} times in 1000");
1278 }
1279}