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 error::ApiError,
37 heartbeat::{self, AgentState},
38 import::{self, AgentDay, AgentPause, AgentTask},
39 model::UserRole,
40 session::hash_password,
41};
42
43pub const PASSWORD: &str = "kasl-demo";
49
50const DOMAIN: &str = "example.com";
53
54const WEEKS: i64 = 8;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum Pattern {
60 Steady,
62 Long,
64 Fading,
67 Breaks,
69 Open,
71 Silent,
73 Never,
75}
76
77struct Person {
79 first: &'static str,
80 last: &'static str,
81 role: UserRole,
82 department: Option<&'static str>,
85 offset_minutes: i32,
88 pattern: Option<Pattern>,
90}
91
92impl Person {
93 fn email(&self) -> String {
94 format!("{}.{}@{DOMAIN}", self.first.to_ascii_lowercase(), self.last.to_ascii_lowercase())
95 }
96
97 fn display_name(&self) -> String {
98 format!("{} {}", self.first, self.last)
99 }
100
101 fn token(&self) -> String {
104 format!("demo-{}", self.first.to_ascii_lowercase())
105 }
106
107 fn offset(&self) -> chrono::FixedOffset {
108 chrono::FixedOffset::east_opt(self.offset_minutes * 60).expect("the offsets in the team table are valid")
109 }
110}
111
112struct Department {
114 name: &'static str,
115 manager: &'static str,
116}
117
118const DEPARTMENTS: [Department; 3] = [
119 Department {
120 name: "Engineering",
121 manager: "priya.raman",
122 },
123 Department {
124 name: "Design",
125 manager: "daniel.okafor",
126 },
127 Department {
128 name: "Support",
129 manager: "elena.novak",
130 },
131];
132
133const TEAM: [Person; 12] = [
135 Person {
136 first: "Sam",
137 last: "Whitfield",
138 role: UserRole::Admin,
139 department: None,
140 offset_minutes: 0,
141 pattern: None,
142 },
143 Person {
144 first: "Priya",
145 last: "Raman",
146 role: UserRole::Manager,
147 department: Some("Engineering"),
148 offset_minutes: 5 * 60 + 30,
149 pattern: Some(Pattern::Steady),
150 },
151 Person {
152 first: "Daniel",
153 last: "Okafor",
154 role: UserRole::Manager,
155 department: Some("Design"),
156 offset_minutes: 60,
157 pattern: Some(Pattern::Steady),
158 },
159 Person {
160 first: "Elena",
161 last: "Novak",
162 role: UserRole::Manager,
163 department: Some("Support"),
164 offset_minutes: 2 * 60,
165 pattern: Some(Pattern::Long),
166 },
167 Person {
168 first: "Tomas",
169 last: "Verhoeven",
170 role: UserRole::Employee,
171 department: Some("Engineering"),
172 offset_minutes: 2 * 60,
173 pattern: Some(Pattern::Steady),
174 },
175 Person {
176 first: "Aiko",
177 last: "Tanaka",
178 role: UserRole::Employee,
179 department: Some("Engineering"),
180 offset_minutes: 9 * 60,
181 pattern: Some(Pattern::Breaks),
182 },
183 Person {
184 first: "Lukas",
185 last: "Brandt",
186 role: UserRole::Employee,
187 department: Some("Engineering"),
188 offset_minutes: 60,
189 pattern: Some(Pattern::Fading),
190 },
191 Person {
192 first: "Sofia",
193 last: "Reyes",
194 role: UserRole::Employee,
195 department: Some("Engineering"),
196 offset_minutes: -3 * 60,
197 pattern: Some(Pattern::Open),
198 },
199 Person {
200 first: "Mira",
201 last: "Halvorsen",
202 role: UserRole::Employee,
203 department: Some("Design"),
204 offset_minutes: 2 * 60,
205 pattern: Some(Pattern::Steady),
206 },
207 Person {
208 first: "Jonas",
209 last: "Petit",
210 role: UserRole::Employee,
211 department: Some("Design"),
212 offset_minutes: 60,
213 pattern: Some(Pattern::Silent),
214 },
215 Person {
216 first: "Yusuf",
217 last: "Demir",
218 role: UserRole::Employee,
219 department: Some("Support"),
220 offset_minutes: 3 * 60,
221 pattern: Some(Pattern::Long),
222 },
223 Person {
224 first: "Hana",
225 last: "Kowalski",
226 role: UserRole::Employee,
227 department: Some("Support"),
228 offset_minutes: 60,
229 pattern: Some(Pattern::Never),
230 },
231];
232
233fn task_pool(department: &str) -> &'static [&'static str] {
235 match department {
236 "Engineering" => &[
237 "Reliable ingest",
238 "Batch upload retries",
239 "Migration to the new query layer",
240 "Review: departments API",
241 "Flaky CI on arm64",
242 "Onboarding checklist",
243 "Release notes for 2.3",
244 "Connection pool tuning",
245 ],
246 "Design" => &[
247 "Dashboard empty states",
248 "Icon set, second pass",
249 "Login screen polish",
250 "Design tokens audit",
251 "Mobile layout study",
252 "Timeline colours in dark mode",
253 ],
254 _ => &[
255 "Ticket triage",
256 "Customer call: Northbridge",
257 "Knowledge base: backups",
258 "Escalation: lost password",
259 "Weekly support digest",
260 "Renewal follow-ups",
261 ],
262 }
263}
264
265const BREAK_REASONS: [&str; 5] = ["Lunch", "School run", "Dentist", "Walk", "Errand"];
267
268#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
270pub struct Account {
271 pub role: UserRole,
272 pub email: String,
273 pub display_name: String,
274}
275
276pub fn showcase() -> Vec<Account> {
279 [UserRole::Manager, UserRole::Employee, UserRole::Admin]
280 .into_iter()
281 .filter_map(|role| TEAM.iter().find(|person| person.role == role))
282 .map(|person| Account {
283 role: person.role,
284 email: person.email(),
285 display_name: person.display_name(),
286 })
287 .collect()
288}
289
290#[derive(Debug, PartialEq, Eq)]
292pub enum Status {
293 Empty,
295 Demo,
297 Populated { accounts: i64 },
299}
300
301pub async fn status(pool: &PgPool) -> Result<Status> {
302 let demo: bool = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton")
303 .fetch_one(pool)
304 .await
305 .context("failed to read the installation's settings")?;
306 if demo {
307 return Ok(Status::Demo);
308 }
309 let accounts: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
310 .fetch_one(pool)
311 .await
312 .context("failed to count accounts")?;
313 Ok(if accounts == 0 { Status::Empty } else { Status::Populated { accounts } })
314}
315
316#[derive(Debug, Default, PartialEq, Eq)]
318pub struct Seeded {
319 pub departments: usize,
320 pub people: usize,
321 pub days: usize,
322}
323
324pub async fn seed(pool: &PgPool, now: DateTime<Utc>) -> Result<Seeded> {
333 match status(pool).await? {
334 Status::Empty => {}
335 Status::Demo => bail!("this database already holds the demo team"),
336 Status::Populated { accounts } => bail!("this database already holds {accounts} accounts; the demo only seeds an empty one"),
337 }
338
339 let mut seeded = Seeded::default();
340
341 let mut tx = pool.begin().await?;
346 sqlx::query("UPDATE settings SET demo = true WHERE singleton").execute(&mut *tx).await?;
347
348 let mut department_ids = Vec::with_capacity(DEPARTMENTS.len());
349 for department in &DEPARTMENTS {
350 let id: Uuid = sqlx::query_scalar("INSERT INTO departments (name) VALUES ($1) RETURNING id")
351 .bind(department.name)
352 .fetch_one(&mut *tx)
353 .await
354 .with_context(|| format!("failed to create the {} department", department.name))?;
355 department_ids.push((department.name, id));
356 seeded.departments += 1;
357 }
358 let department_id = |name: &str| department_ids.iter().find(|(n, _)| *n == name).map(|(_, id)| *id);
359
360 let password_hash = hash_password(PASSWORD)?;
363
364 let mut user_ids = Vec::with_capacity(TEAM.len());
365 for person in &TEAM {
366 let id: Uuid = sqlx::query_scalar(
367 "INSERT INTO users (email, display_name, role, password_hash, active, department_id)
368 VALUES ($1, $2, $3, $4, true, $5) RETURNING id",
369 )
370 .bind(person.email())
371 .bind(person.display_name())
372 .bind(person.role)
373 .bind(&password_hash)
374 .bind(person.department.and_then(department_id))
375 .fetch_one(&mut *tx)
376 .await
377 .with_context(|| format!("failed to create the account for {}", person.display_name()))?;
378
379 if person.pattern.is_some() {
380 sqlx::query("INSERT INTO agents (user_id, name, token_hash) VALUES ($1, $2, $3)")
381 .bind(id)
382 .bind(format!("{}-laptop", person.first.to_ascii_lowercase()))
383 .bind(hash_token(&person.token()))
384 .execute(&mut *tx)
385 .await
386 .with_context(|| format!("failed to create the agent for {}", person.display_name()))?;
387 }
388
389 user_ids.push(id);
390 seeded.people += 1;
391 }
392
393 for department in &DEPARTMENTS {
394 let manager = TEAM
395 .iter()
396 .position(|person| person.email().starts_with(department.manager))
397 .expect("every department in the table names a member of the team");
398 sqlx::query("UPDATE departments SET manager_id = $1 WHERE name = $2")
399 .bind(user_ids[manager])
400 .bind(department.name)
401 .execute(&mut *tx)
402 .await?;
403 }
404 tx.commit().await?;
405
406 for (index, person) in TEAM.iter().enumerate() {
407 let Some(pattern) = person.pattern else { continue };
408 let days = days_for(person, pattern, index as u64, now);
409 let mut tx = pool.begin().await?;
412 for day in &days {
413 import::write_day(&mut tx, user_ids[index], day, person.offset()).await?;
414 }
415 tx.commit().await?;
416 seeded.days += days.len();
417
418 let last_seen: Option<DateTime<Utc>> = match pattern {
423 Pattern::Never => None,
424 Pattern::Open => Some(now),
425 _ => days
426 .iter()
427 .filter_map(|day| day.end)
428 .max()
429 .map(|end| import::at_offset(end, person.offset()).with_timezone(&Utc)),
430 };
431 let (pulse, age) = pulse_for(pattern);
432 sqlx::query(
433 "UPDATE agents SET last_seen_at = $2, heartbeat_state = $3, demo_pulse_age_seconds = $4,
434 heartbeat_at = CASE WHEN $3 IS NULL THEN NULL ELSE now() - coalesce($4, 0) * interval '1 second' END,
435 heartbeat_received_at = CASE WHEN $3 IS NULL THEN NULL ELSE now() - coalesce($4, 0) * interval '1 second' END
436 WHERE user_id = $1",
437 )
438 .bind(user_ids[index])
439 .bind(last_seen)
440 .bind(pulse)
441 .bind(age)
442 .execute(pool)
443 .await?;
444 }
445
446 audit::Entry::new(audit::action::DEMO_SEEDED)
447 .with(serde_json::json!({ "people": seeded.people, "departments": seeded.departments, "days": seeded.days }))
448 .record(pool)
449 .await;
450
451 Ok(seeded)
452}
453
454fn pulse_for(pattern: Pattern) -> (Option<AgentState>, Option<i32>) {
460 let state = match pattern {
461 Pattern::Open => Some(AgentState::Working),
463 Pattern::Breaks => Some(AgentState::Paused),
466 Pattern::Steady => Some(AgentState::Idle),
470 Pattern::Long | Pattern::Fading => Some(AgentState::Working),
475 _ => None,
477 };
478 let age = state.map(|_| {
483 if matches!(pattern, Pattern::Long | Pattern::Fading) {
484 STALE_PULSE_AGE_SECONDS
485 } else {
486 0
487 }
488 });
489 (state, age)
490}
491
492pub async fn ensure_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
505 let mut given = 0;
506 for person in TEAM.iter() {
507 let Some(pattern) = person.pattern else { continue };
508 let (Some(state), age) = pulse_for(pattern) else { continue };
509 let updated = sqlx::query(
510 "UPDATE agents SET heartbeat_state = $2, demo_pulse_age_seconds = $3,
511 heartbeat_at = now() - coalesce($3, 0) * interval '1 second',
512 heartbeat_received_at = now() - coalesce($3, 0) * interval '1 second'
513 FROM users u
514 WHERE agents.user_id = u.id AND lower(u.email) = lower($1)
515 AND agents.heartbeat_state IS NULL AND agents.revoked_at IS NULL",
516 )
517 .bind(person.email())
518 .bind(state)
519 .bind(age)
520 .execute(pool)
521 .await?;
522 given += updated.rows_affected();
523 }
524 Ok(given)
525}
526
527pub async fn refresh_pulses(pool: &PgPool) -> Result<u64, sqlx::Error> {
549 let updated = sqlx::query(
553 "UPDATE agents
554 SET heartbeat_at = now() - demo_pulse_age_seconds * interval '1 second',
555 heartbeat_received_at = now() - demo_pulse_age_seconds * interval '1 second'
556 WHERE heartbeat_state IS NOT NULL AND demo_pulse_age_seconds IS NOT NULL AND revoked_at IS NULL",
557 )
558 .execute(pool)
559 .await?;
560 Ok(updated.rows_affected())
561}
562
563const STALE_PULSE_AGE_SECONDS: i32 = (heartbeat::STALE_AFTER_SECONDS * 4) as i32;
569
570pub fn keep_pulses_fresh(pool: PgPool) {
576 let period = std::time::Duration::from_secs((heartbeat::STALE_AFTER_SECONDS / 2).max(1) as u64);
577 tokio::spawn(async move {
578 let mut ticker = tokio::time::interval(period);
579 ticker.tick().await;
582 loop {
583 ticker.tick().await;
584 if let Err(error) = refresh_pulses(&pool).await {
585 tracing::warn!(%error, "failed to refresh the demo pulses");
589 }
590 }
591 });
592}
593
594fn days_for(person: &Person, pattern: Pattern, index: u64, now: DateTime<Utc>) -> Vec<AgentDay> {
600 if pattern == Pattern::Never {
601 return Vec::new();
602 }
603
604 let mut rng = Rng::new(index);
605 let now_local = now.with_timezone(&person.offset()).naive_local();
606 let today = now_local.date();
607 let first = today - Duration::weeks(WEEKS);
608 let pool = task_pool(person.department.unwrap_or("Support"));
609
610 let mut carried: Vec<(i32, &'static str, i16)> = Vec::new();
613 let mut next_task_id = 1;
614 let mut days = Vec::new();
615
616 for offset in 0..(today - first).num_days() {
617 let date = first + Duration::days(offset);
618
619 if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
620 continue;
621 }
622 if rng.chance(4) {
624 continue;
625 }
626 if pattern == Pattern::Silent && date >= today - Duration::days(7) {
627 continue;
628 }
629
630 let week = ((date - first).num_days() / 7) as f64;
631 let (start_minute, span_minutes) = match pattern {
632 Pattern::Long => (rng.range(8 * 60, 8 * 60 + 30), rng.range(10 * 60, 11 * 60 + 15)),
633 Pattern::Fading => (rng.range(9 * 60, 9 * 60 + 45), (8.5 * 60.0 - week * 0.45 * 60.0) as i64 + rng.range(-15, 15)),
634 _ => (rng.range(8 * 60 + 45, 9 * 60 + 30), rng.range(7 * 60 + 45, 8 * 60 + 45)),
635 };
636 let start = date.and_time(minutes(start_minute));
637 let end = start + Duration::minutes(span_minutes);
638
639 let pauses = pauses_for(&mut rng, pattern, start, span_minutes);
640 let tasks = tasks_for(&mut rng, pool, &mut carried, &mut next_task_id, end);
641
642 days.push(AgentDay {
643 date,
644 start,
645 end: Some(end),
646 pauses,
647 tasks,
648 });
649 }
650
651 if pattern == Pattern::Open {
652 let start = (now_local - Duration::hours(3)).max(today.and_time(NaiveTime::MIN));
656 let elapsed = (now_local - start).num_minutes();
657 let mut pauses = Vec::new();
658 if elapsed > 90 {
659 let pause_start = start + Duration::minutes(elapsed / 2);
660 pauses.push(AgentPause {
661 start: pause_start,
662 end: Some(pause_start + Duration::minutes(11)),
663 duration_seconds: Some(11 * 60),
664 manual: false,
665 reason: None,
666 });
667 }
668 let name = carried.first().map(|(_, name, _)| *name).unwrap_or(pool[0]);
669 let group = carried.first().map(|(group, _, _)| *group).unwrap_or(next_task_id);
670 days.push(AgentDay {
671 date: today,
672 start,
673 end: None,
674 pauses,
675 tasks: vec![AgentTask {
676 agent_task_id: next_task_id,
677 agent_group_id: group,
678 recorded_at: now_local - Duration::minutes(elapsed.min(20)),
679 name: name.to_string(),
680 comment: None,
681 completeness: 40,
682 }],
683 });
684 }
685
686 days
687}
688
689fn pauses_for(rng: &mut Rng, pattern: Pattern, start: NaiveDateTime, span_minutes: i64) -> Vec<AgentPause> {
696 let manual = pattern == Pattern::Breaks;
697 let hours = span_minutes / 60;
698 let mut pauses = Vec::new();
699
700 let lunch = start + Duration::hours(4) + Duration::minutes(rng.range(0, 15));
701 pauses.push(pause(lunch, rng.range(28, 40), manual, manual.then_some("Lunch")));
702
703 let idle_count = match pattern {
704 Pattern::Long => rng.range(1, 2),
705 Pattern::Breaks => rng.range(1, 3),
706 _ => rng.range(2, 3),
707 };
708 let mut slots: Vec<i64> = (1..hours).filter(|slot| *slot != 4).collect();
709 for _ in 0..idle_count {
710 if slots.is_empty() {
711 break;
712 }
713 let slot = slots.remove(rng.range(0, slots.len() as i64 - 1) as usize);
714 let at = start + Duration::hours(slot) + Duration::minutes(rng.range(0, 30));
715 let reason = manual.then(|| *rng.pick(&BREAK_REASONS[1..]));
716 pauses.push(pause(at, rng.range(5, 25), manual, reason));
717 }
718
719 pauses.sort_by_key(|pause| pause.start);
720 pauses
721}
722
723fn pause(start: NaiveDateTime, minutes: i64, manual: bool, reason: Option<&str>) -> AgentPause {
724 AgentPause {
725 start,
726 end: Some(start + Duration::minutes(minutes)),
727 duration_seconds: Some((minutes * 60) as i32),
728 manual,
729 reason: reason.map(str::to_string),
730 }
731}
732
733fn tasks_for(rng: &mut Rng, pool: &[&'static str], carried: &mut Vec<(i32, &'static str, i16)>, next_id: &mut i32, end: NaiveDateTime) -> Vec<AgentTask> {
739 let count = rng.range(2, 4);
740 let mut tasks = Vec::new();
741 let mut still_open = Vec::new();
742
743 for _ in 0..count {
744 let continued = !carried.is_empty() && rng.chance(55);
745 let (group, name, completeness) = if continued {
746 let (group, name, done) = carried.remove(0);
747 (group, name, (done + rng.range(20, 50) as i16).min(100))
748 } else {
749 let name = *rng.pick(pool);
750 (*next_id, name, [20i16, 40, 60, 80, 100][rng.range(0, 4) as usize])
751 };
752 let id = *next_id;
753 *next_id += 1;
754
755 tasks.push(AgentTask {
756 agent_task_id: id,
757 agent_group_id: group,
758 recorded_at: end - Duration::minutes(rng.range(2, 15)),
759 name: name.to_string(),
760 comment: None,
761 completeness,
762 });
763 if completeness < 100 && still_open.len() < 2 {
764 still_open.push((group, name, completeness));
765 }
766 }
767
768 *carried = still_open;
769 tasks
770}
771
772fn minutes(since_midnight: i64) -> NaiveTime {
773 NaiveTime::from_num_seconds_from_midnight_opt((since_midnight * 60) as u32, 0).expect("a minute of the day")
774}
775
776struct Rng(u64);
782
783impl Rng {
784 fn new(seed: u64) -> Self {
785 Self((seed + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
788 }
789
790 fn next(&mut self) -> u64 {
791 let mut x = self.0;
792 x ^= x >> 12;
793 x ^= x << 25;
794 x ^= x >> 27;
795 self.0 = x;
796 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
797 }
798
799 fn range(&mut self, low: i64, high: i64) -> i64 {
801 debug_assert!(low <= high);
802 low + (self.next() % (high - low + 1) as u64) as i64
803 }
804
805 fn chance(&mut self, percent: u64) -> bool {
806 self.next() % 100 < percent
807 }
808
809 fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
810 &items[self.range(0, items.len() as i64 - 1) as usize]
811 }
812}
813
814#[derive(Debug, Serialize)]
816struct Accounts {
817 password: &'static str,
818 accounts: Vec<Account>,
819}
820
821pub async fn accounts(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
827 let demo: bool = sqlx::query_scalar("SELECT demo FROM settings WHERE singleton").fetch_one(&state.pool).await?;
828 if !demo {
829 return Err(ApiError::new(StatusCode::NOT_FOUND, "this server is not a demo"));
830 }
831 Ok(Json(Accounts {
832 password: PASSWORD,
833 accounts: showcase(),
834 }))
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 fn at(date: &str, time: &str) -> DateTime<Utc> {
842 format!("{date}T{time}Z").parse().expect("a test timestamp")
843 }
844
845 fn person(pattern: Pattern) -> &'static Person {
846 TEAM.iter().find(|person| person.pattern == Some(pattern)).expect("every pattern has a person")
847 }
848
849 #[test]
850 fn every_department_names_a_manager_who_is_in_the_team() {
851 for department in &DEPARTMENTS {
852 let manager = TEAM.iter().find(|person| person.email().starts_with(department.manager));
853 let manager = manager.unwrap_or_else(|| panic!("{} names nobody in the team", department.name));
854 assert_eq!(manager.role, UserRole::Manager, "{} is run by a {:?}", department.name, manager.role);
855 assert_eq!(manager.department, Some(department.name), "a manager belongs to the department they run");
856 }
857 }
858
859 #[test]
860 fn the_team_has_one_of_every_role_and_every_pattern() {
861 for role in [UserRole::Admin, UserRole::Manager, UserRole::Employee] {
862 assert!(TEAM.iter().any(|person| person.role == role), "nobody is a {role:?}");
863 }
864 for pattern in [
865 Pattern::Steady,
866 Pattern::Long,
867 Pattern::Fading,
868 Pattern::Breaks,
869 Pattern::Open,
870 Pattern::Silent,
871 Pattern::Never,
872 ] {
873 assert!(TEAM.iter().any(|person| person.pattern == Some(pattern)), "nobody is {pattern:?}");
874 }
875 assert_eq!(showcase().len(), 3, "one account of each role to try");
876 assert_eq!(showcase()[0].role, UserRole::Manager, "the manager's dashboard is what the demo is for");
877 }
878
879 #[test]
880 fn emails_are_under_a_reserved_domain() {
881 for person in &TEAM {
883 assert!(person.email().ends_with("@example.com"), "{}", person.email());
884 }
885 let mut emails: Vec<String> = TEAM.iter().map(Person::email).collect();
886 emails.dedup();
887 assert_eq!(emails.len(), TEAM.len(), "two people share an address");
888 }
889
890 #[test]
891 fn the_history_is_the_same_for_the_same_today() {
892 let now = at("2026-08-29", "10:00:00");
895 let person = person(Pattern::Steady);
896 let one = days_for(person, Pattern::Steady, 4, now);
897 let two = days_for(person, Pattern::Steady, 4, now);
898 assert_eq!(one.len(), two.len());
899 for (a, b) in one.iter().zip(&two) {
900 assert_eq!(
901 (a.date, a.start, a.end, a.pauses.len(), a.tasks.len()),
902 (b.date, b.start, b.end, b.pauses.len(), b.tasks.len())
903 );
904 }
905 assert!(
906 one.len() >= 35,
907 "eight weeks of weekdays, minus the odd sick day, is at least 35 days; got {}",
908 one.len()
909 );
910 }
911
912 #[test]
913 fn days_end_yesterday_and_skip_weekends() {
914 let now = at("2026-08-29", "10:00:00");
915 let person = person(Pattern::Steady);
916 let days = days_for(person, Pattern::Steady, 4, now);
917 let today = now.with_timezone(&person.offset()).date_naive();
918 for day in &days {
919 assert!(day.date < today, "{} is not in the past", day.date);
920 assert!(!matches!(day.date.weekday(), Weekday::Sat | Weekday::Sun), "{} is a weekend", day.date);
921 assert!(day.end.is_some(), "every past day is closed");
922 let end = day.end.unwrap();
923 for pause in &day.pauses {
924 assert!(pause.start > day.start && pause.end.unwrap() < end, "a pause lies inside its day");
925 }
926 for pair in day.pauses.windows(2) {
927 assert!(pair[0].end.unwrap() <= pair[1].start, "pauses do not overlap on {}", day.date);
928 }
929 }
930 }
931
932 #[test]
933 fn the_open_day_is_today_and_still_running() {
934 let now = at("2026-08-29", "14:00:00");
935 let person = person(Pattern::Open);
936 let days = days_for(person, Pattern::Open, 7, now);
937 let today = now.with_timezone(&person.offset()).date_naive();
938 let open = days.iter().find(|day| day.end.is_none()).expect("one day is open");
939 assert_eq!(open.date, today);
940 assert!(open.start <= now.with_timezone(&person.offset()).naive_local());
941 assert_eq!(days.iter().filter(|day| day.end.is_none()).count(), 1);
942 }
943
944 #[test]
945 fn an_open_day_started_after_midnight_does_not_reach_into_yesterday() {
946 let person = person(Pattern::Open);
948 let now = at("2026-08-29", "04:00:00"); let days = days_for(person, Pattern::Open, 7, now);
950 let open = days.iter().find(|day| day.end.is_none()).unwrap();
951 assert_eq!(open.start, open.date.and_time(NaiveTime::MIN));
952 }
953
954 #[test]
955 fn the_silent_agent_stops_a_week_before_today() {
956 let now = at("2026-08-29", "10:00:00");
957 let person = person(Pattern::Silent);
958 let days = days_for(person, Pattern::Silent, 9, now);
959 let today = now.with_timezone(&person.offset()).date_naive();
960 assert!(!days.is_empty());
961 assert!(days.iter().all(|day| day.date < today - Duration::days(7)), "nothing in the last week");
962 }
963
964 #[test]
965 fn the_fading_hours_actually_fade() {
966 let now = at("2026-08-29", "10:00:00");
967 let person = person(Pattern::Fading);
968 let days = days_for(person, Pattern::Fading, 6, now);
969 let span = |day: &AgentDay| (day.end.unwrap() - day.start).num_minutes();
970 let first_week: Vec<i64> = days.iter().take(5).map(span).collect();
971 let last_week: Vec<i64> = days.iter().rev().take(5).map(span).collect();
972 let average = |week: &[i64]| week.iter().sum::<i64>() / week.len() as i64;
973 assert!(
974 average(&first_week) - average(&last_week) > 120,
975 "the last week should be hours shorter than the first: {first_week:?} vs {last_week:?}"
976 );
977 }
978
979 #[test]
980 fn tasks_carry_across_days_under_one_group() {
981 let now = at("2026-08-29", "10:00:00");
982 let person = person(Pattern::Steady);
983 let days = days_for(person, Pattern::Steady, 4, now);
984 let mut ids = std::collections::HashSet::new();
985 let mut carried = 0;
986 for day in &days {
987 for task in &day.tasks {
988 assert!(ids.insert(task.agent_task_id), "agent_task_id {} repeats", task.agent_task_id);
989 if task.agent_group_id != task.agent_task_id {
990 carried += 1;
991 }
992 }
993 }
994 assert!(carried > 0, "some work should span more than one day");
995 }
996
997 #[test]
998 fn the_never_pattern_has_no_days() {
999 let person = person(Pattern::Never);
1000 assert!(days_for(person, Pattern::Never, 11, at("2026-08-29", "10:00:00")).is_empty());
1001 }
1002
1003 #[test]
1004 fn the_generator_stays_in_range() {
1005 let mut rng = Rng::new(3);
1006 for _ in 0..1000 {
1007 let value = rng.range(5, 7);
1008 assert!((5..=7).contains(&value));
1009 }
1010 let mut heads = 0;
1011 for _ in 0..1000 {
1012 if rng.chance(50) {
1013 heads += 1;
1014 }
1015 }
1016 assert!((350..=650).contains(&heads), "a 50% chance came up {heads} times in 1000");
1017 }
1018}