headless_lms_models/library/credit_registration/
course_unit_poll_slots.rs1use chrono::TimeDelta;
7use headless_lms_utils::backoff::next_attempt_at;
8
9use crate::prelude::*;
10
11const MORNING_SLOT: TimeDelta = TimeDelta::minutes(7 * 60 + 15);
13const CATCH_UP_SLOT: TimeDelta = TimeDelta::hours(12);
15pub const SLOT_SPREAD: TimeDelta = TimeDelta::minutes(30);
17
18pub async fn next_course_unit_polls(
22 conn: &mut PgConnection,
23 now: DateTime<Utc>,
24 first_seen_at: &[DateTime<Utc>],
25) -> ModelResult<Vec<DateTime<Utc>>> {
26 Ok(next_slots(conn, now, first_seen_at)
27 .await?
28 .into_iter()
29 .map(|slot| next_attempt_at(slot, 0, SLOT_SPREAD.num_seconds()))
30 .collect())
31}
32
33async fn next_slots(
36 conn: &mut PgConnection,
37 now: DateTime<Utc>,
38 first_seen_at: &[DateTime<Utc>],
39) -> ModelResult<Vec<DateTime<Utc>>> {
40 let slots = sqlx::query_scalar!(
41 r#"
42SELECT (
43 CASE
44 WHEN slot.local_now < slot.morning THEN slot.morning
45 WHEN slot.local_now < slot.catch_up
46 AND slot.local_first_seen < slot.morning THEN slot.catch_up
47 ELSE slot.morning + INTERVAL '1 day'
48 END
49 ) AT TIME ZONE 'Europe/Helsinki' AS "slot!: DateTime<Utc>"
50FROM UNNEST($2::timestamptz []) WITH ORDINALITY AS seen(first_seen_at, position)
51 CROSS JOIN LATERAL (
52 SELECT $1::timestamptz AT TIME ZONE 'Europe/Helsinki' AS local_now,
53 seen.first_seen_at AT TIME ZONE 'Europe/Helsinki' AS local_first_seen,
54 ($1::timestamptz AT TIME ZONE 'Europe/Helsinki')::date + $3::interval AS morning,
55 ($1::timestamptz AT TIME ZONE 'Europe/Helsinki')::date + $4::interval AS catch_up
56 ) slot
57ORDER BY seen.position
58 "#,
59 now,
60 first_seen_at,
61 MORNING_SLOT as TimeDelta,
62 CATCH_UP_SLOT as TimeDelta,
63 )
64 .fetch_all(conn)
65 .await?;
66 Ok(slots)
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use crate::test_helper::*;
73
74 fn utc(at: &str) -> DateTime<Utc> {
75 DateTime::parse_from_rfc3339(at).unwrap().to_utc()
76 }
77
78 async fn slot(conn: &mut PgConnection, now: &str, first_seen_at: &str) -> DateTime<Utc> {
79 next_slots(conn, utc(now), &[utc(first_seen_at)])
80 .await
81 .unwrap()[0]
82 }
83
84 #[tokio::test]
85 async fn a_row_seen_overnight_polls_the_same_morning_and_then_at_noon() {
86 let mut conn = Conn::init().await;
87 let mut tx = conn.begin().await;
88 let seen = "2027-01-12T03:30:00+02:00";
89 assert_eq!(
90 slot(tx.as_mut(), seen, seen).await,
91 utc("2027-01-12T07:15:00+02:00")
92 );
93 assert_eq!(
94 slot(tx.as_mut(), "2027-01-12T07:40:00+02:00", seen).await,
95 utc("2027-01-12T12:00:00+02:00")
96 );
97 assert_eq!(
98 slot(tx.as_mut(), "2027-01-12T12:20:00+02:00", seen).await,
99 utc("2027-01-13T07:15:00+02:00")
100 );
101 }
102
103 #[tokio::test]
104 async fn a_row_seen_during_the_day_waits_for_the_next_morning() {
105 let mut conn = Conn::init().await;
106 let mut tx = conn.begin().await;
107 let seen = "2026-08-10T09:00:00+03:00";
108 assert_eq!(
109 slot(tx.as_mut(), seen, seen).await,
110 utc("2026-08-11T07:15:00+03:00")
111 );
112 let evening = "2026-08-10T22:00:00+03:00";
113 assert_eq!(
114 slot(tx.as_mut(), evening, evening).await,
115 utc("2026-08-11T07:15:00+03:00")
116 );
117 }
118
119 #[tokio::test]
120 async fn slots_keep_finnish_wall_clock_time_across_summer_time_changes() {
121 let mut conn = Conn::init().await;
122 let mut tx = conn.begin().await;
123 assert_eq!(
125 slot(
126 tx.as_mut(),
127 "2026-10-24T12:30:00+03:00",
128 "2026-10-24T03:00:00+03:00"
129 )
130 .await,
131 utc("2026-10-25T07:15:00+02:00")
132 );
133 let in_the_repeated_hour = "2026-10-25T03:30:00+03:00";
134 assert_eq!(
135 slot(tx.as_mut(), in_the_repeated_hour, in_the_repeated_hour).await,
136 utc("2026-10-25T07:15:00+02:00")
137 );
138 assert_eq!(
140 slot(
141 tx.as_mut(),
142 "2027-03-27T13:00:00+02:00",
143 "2027-03-27T13:00:00+02:00"
144 )
145 .await,
146 utc("2027-03-28T07:15:00+03:00")
147 );
148 assert_eq!(
149 slot(
150 tx.as_mut(),
151 "2027-03-28T07:30:00+03:00",
152 "2027-03-28T02:30:00+02:00"
153 )
154 .await,
155 utc("2027-03-28T12:00:00+03:00")
156 );
157 }
158}