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,
65 Fading,
68 Breaks,
70 PartTime,
75 Open,
77 Silent,
79 Never,
81}
82
83struct Person {
85 first: &'static str,
86 last: &'static str,
87 role: UserRole,
88 department: Option<&'static str>,
91 offset_minutes: i32,
94 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 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
118struct 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
139const 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
239fn 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
271const BREAK_REASONS: [&str; 5] = ["Lunch", "School run", "Dentist", "Walk", "Errand"];
273
274#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
276pub struct Account {
277 pub role: UserRole,
278 pub email: String,
279 pub display_name: String,
280}
281
282pub 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#[derive(Debug, PartialEq, Eq)]
298pub enum Status {
299 Empty,
301 Demo,
303 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#[derive(Debug, Default, PartialEq, Eq)]
324pub struct Seeded {
325 pub departments: usize,
326 pub people: usize,
327 pub days: usize,
328 pub calendar_days: usize,
330}
331
332const PART_TIME: [(&str, &str); 1] = [("mira.halvorsen", "0.6")];
343
344const 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
355pub 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 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 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 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 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 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 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
516fn 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
536fn pulse_for(pattern: Pattern) -> (Option<AgentState>, Option<i32>) {
542 let state = match pattern {
543 Pattern::Open => Some(AgentState::Working),
545 Pattern::Breaks => Some(AgentState::Paused),
548 Pattern::Steady => Some(AgentState::Idle),
552 Pattern::Long | Pattern::Fading => Some(AgentState::Working),
557 _ => None,
559 };
560 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
574pub 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
609pub async fn refresh_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
631 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
645const STALE_PULSE_AGE_SECONDS: i32 = (heartbeat::STALE_AFTER_SECONDS * 4) as i32;
651
652pub 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 ticker.tick().await;
664 loop {
665 ticker.tick().await;
666 if let Err(error) = refresh_pulses(&pool).await {
667 tracing::warn!(%error, "failed to refresh the demo pulses");
671 }
672 }
673 });
674}
675
676fn 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 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 if rng.chance(8) {
718 if rng.chance(60) {
719 days.push(AgentDay {
720 date,
721 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 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 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
798fn 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 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
847fn 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
890struct Rng(u64);
896
897impl Rng {
898 fn new(seed: u64) -> Self {
899 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 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#[derive(Debug, Serialize)]
930struct Accounts {
931 password: &'static str,
932 accounts: Vec<Account>,
933}
934
935pub 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 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 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 let person = person(Pattern::Open);
1062 let now = at("2026-08-29", "04:00:00"); 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 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}