1use super::attention::{AttentionReason, AttentionRegistration};
4use super::state::{CreditRegistrationErrorCode, CreditRegistrationState};
5use crate::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
6use crate::library::credit_registration::PendingReasonCounts;
7use crate::prelude::*;
8use chrono::TimeDelta;
9use std::collections::HashMap;
10use utoipa::ToSchema;
11
12pub async fn count_by_state(
15 conn: &mut PgConnection,
16) -> ModelResult<Vec<(CreditRegistrationState, i64)>> {
17 let rows = sqlx::query!(
18 r#"
19SELECT state,
20 COUNT(*) AS "count!"
21FROM credit_registrations
22WHERE superseded_by_id IS NULL
23 AND deleted_at IS NULL
24GROUP BY state
25 "#,
26 )
27 .fetch_all(conn)
28 .await?;
29 Ok(rows.into_iter().map(|r| (r.state, r.count)).collect())
30}
31
32pub async fn count_due_enrolment_checks(conn: &mut PgConnection) -> ModelResult<i64> {
40 let count = sqlx::query_scalar!(
41 r#"
42SELECT COUNT(*) AS "count!"
43FROM credit_registrations cr
44 JOIN credit_registration_active_course_modules acm ON acm.course_module_id = cr.course_module_id
45 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
46WHERE cr.state = 'no_usable_enrolment'
47 AND cr.next_attempt_at <= now()
48 AND cr.superseded_by_id IS NULL
49 AND cr.deleted_at IS NULL
50 AND (
51 cmc.register_credits_via_suotar
52 OR cr.submitted_at IS NOT NULL
53 )
54 AND (
55 cr.enrolment_check_claimed_until IS NULL
56 OR cr.enrolment_check_claimed_until <= now()
57 )
58 AND NOT EXISTS (
59 SELECT 1
60 FROM credit_registration_test_exclusive_holds h
61 WHERE h.user_id = cr.user_id
62 AND (
63 h.course_id IS NULL
64 OR h.course_id = cr.course_id
65 )
66 AND h.held_until > now()
67 )
68 "#,
69 )
70 .fetch_one(conn)
71 .await?;
72 Ok(count)
73}
74
75pub async fn count_pending_by_reason(conn: &mut PgConnection) -> ModelResult<PendingReasonCounts> {
78 let row = sqlx::query!(
79 r#"
80SELECT COUNT(*) FILTER (
81 WHERE NOT p.completion_eligible
82 ) AS "completion_count!",
83 COUNT(*) FILTER (
84 WHERE p.completion_eligible
85 AND NOT p.has_verified_student_number
86 ) AS "student_number_count!",
87 COUNT(*) FILTER (
88 WHERE p.completion_eligible
89 AND p.has_verified_student_number
90 AND NOT p.course_code_allowed
91 ) AS "course_code_count!"
92FROM credit_registrations cr
93 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
94WHERE cr.state = 'pending'
95 AND cr.superseded_by_id IS NULL
96 AND cr.deleted_at IS NULL
97 "#,
98 )
99 .fetch_one(conn)
100 .await?;
101 Ok(PendingReasonCounts {
102 completion_count: row.completion_count,
103 student_number_count: row.student_number_count,
104 course_code_count: row.course_code_count,
105 })
106}
107
108#[derive(Debug, Clone, PartialEq)]
110pub struct WaitingForStudentNumber {
111 pub credit_registration_id: Uuid,
112 pub user_id: Uuid,
113 pub email: Option<String>,
114 pub first_name: Option<String>,
115 pub last_name: Option<String>,
116 pub course_id: Uuid,
117 pub course_name: String,
118 pub course_module_name: Option<String>,
119 pub uh_course_code: Option<String>,
120 pub completion_date: DateTime<Utc>,
121 pub last_visited_at: Option<DateTime<Utc>>,
122 pub last_check_requested_at: Option<DateTime<Utc>>,
124 pub pressed_at: Option<DateTime<Utc>>,
126}
127
128pub async fn get_waiting_for_student_number(
131 conn: &mut PgConnection,
132 since: Option<DateTime<Utc>>,
133 limit: i64,
134) -> ModelResult<(Vec<WaitingForStudentNumber>, i64)> {
135 let rows = sqlx::query!(
136 r#"
137SELECT cr.id AS credit_registration_id,
138 cr.user_id,
139 ud.email AS "email?",
140 ud.first_name AS "first_name?",
141 ud.last_name AS "last_name?",
142 cr.course_id,
143 c.name AS course_name,
144 cm.name AS "course_module_name?",
145 cm.uh_course_code AS "uh_course_code?",
146 cmc.completion_date,
147 sig.last_visited_at AS "last_visited_at?",
148 CASE
149 WHEN sig.check_request_source = 'student_request' THEN sig.last_check_requested_at
150 END AS "last_check_requested_at?",
151 route.enrolment_confirmed_at AS "pressed_at?",
152 COUNT(*) OVER () AS "total!"
153FROM credit_registrations cr
154 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
155 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
156 AND cmc.deleted_at IS NULL
157 JOIN courses c ON c.id = cr.course_id
158 AND c.deleted_at IS NULL
159 JOIN course_modules cm ON cm.id = cr.course_module_id
160 AND cm.deleted_at IS NULL
161 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
162 LEFT JOIN credit_registration_enrolment_check_signals sig ON sig.course_module_completion_id = cr.course_module_completion_id
163 AND sig.deleted_at IS NULL
164 LEFT JOIN credit_registration_enrolment_routes route ON route.course_module_completion_id = cr.course_module_completion_id
165 AND route.deleted_at IS NULL
166WHERE cr.state = 'pending'
167 AND cr.superseded_by_id IS NULL
168 AND cr.deleted_at IS NULL
169 AND p.completion_eligible
170 AND NOT p.has_verified_student_number
171 AND ($1::timestamptz IS NULL OR cmc.completion_date >= $1)
172ORDER BY cmc.completion_date,
173 cr.id
174LIMIT $2
175 "#,
176 since,
177 limit,
178 )
179 .fetch_all(conn)
180 .await?;
181 let total = rows.first().map_or(0, |row| row.total);
182 Ok((
183 rows.into_iter()
184 .map(|row| WaitingForStudentNumber {
185 credit_registration_id: row.credit_registration_id,
186 user_id: row.user_id,
187 email: row.email,
188 first_name: row.first_name,
189 last_name: row.last_name,
190 course_id: row.course_id,
191 course_name: row.course_name,
192 course_module_name: row.course_module_name,
193 uh_course_code: row.uh_course_code,
194 completion_date: row.completion_date,
195 last_visited_at: row.last_visited_at,
196 last_check_requested_at: row.last_check_requested_at,
197 pressed_at: row.pressed_at,
198 })
199 .collect(),
200 total,
201 ))
202}
203
204#[derive(Debug, Clone, PartialEq)]
207pub struct WaitingPresser {
208 pub credit_registration_id: Uuid,
209 pub user_id: Uuid,
210 pub email: Option<String>,
211 pub first_name: Option<String>,
212 pub last_name: Option<String>,
213 pub course_id: Uuid,
214 pub course_name: String,
215 pub course_module_id: Uuid,
216 pub course_module_name: Option<String>,
217 pub uh_course_code: Option<String>,
219 pub completion_date: DateTime<Utc>,
220 pub pressed_at: DateTime<Utc>,
221 pub enrolment_route: CreditRegistrationEnrolmentRoute,
222 pub linking_emails_on_code_since_press: i64,
225 pub last_linking_email_on_code_at: Option<DateTime<Utc>>,
226}
227
228pub async fn get_waiting_pressers(
231 conn: &mut PgConnection,
232 since: Option<DateTime<Utc>>,
233 credit_registration_id: Option<Uuid>,
234) -> ModelResult<Vec<WaitingPresser>> {
235 let res = sqlx::query_as!(
236 WaitingPresser,
237 r#"
238SELECT cr.id AS credit_registration_id,
239 cr.user_id,
240 ud.email AS "email?",
241 ud.first_name AS "first_name?",
242 ud.last_name AS "last_name?",
243 cr.course_id,
244 c.name AS course_name,
245 cr.course_module_id,
246 cm.name AS "course_module_name?",
247 NULLIF(TRIM(cm.uh_course_code), '') AS "uh_course_code?",
248 cmc.completion_date,
249 route.enrolment_confirmed_at AS "pressed_at!",
250 route.route AS "enrolment_route: CreditRegistrationEnrolmentRoute",
251 emails.count AS "linking_emails_on_code_since_press!",
252 emails.last_at AS "last_linking_email_on_code_at?"
253FROM credit_registrations cr
254 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
255 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
256 AND cmc.deleted_at IS NULL
257 JOIN courses c ON c.id = cr.course_id
258 AND c.deleted_at IS NULL
259 JOIN course_modules cm ON cm.id = cr.course_module_id
260 AND cm.deleted_at IS NULL
261 JOIN credit_registration_enrolment_routes route ON route.course_module_completion_id = cr.course_module_completion_id
262 AND route.deleted_at IS NULL
263 AND route.enrolment_confirmed_at IS NOT NULL
264 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
265 CROSS JOIN LATERAL (
266 SELECT COUNT(*) AS count,
267 MAX(e.sent_at) AS last_at
268 FROM credit_registration_account_linking_emails e
269 JOIN course_modules code_module ON code_module.course_id = e.course_id
270 AND code_module.deleted_at IS NULL
271 WHERE TRIM(code_module.uh_course_code) = TRIM(cm.uh_course_code)
272 AND e.sent_at >= route.enrolment_confirmed_at
273 AND e.deleted_at IS NULL
274 ) emails
275WHERE cr.state = 'pending'
276 AND cr.superseded_by_id IS NULL
277 AND cr.deleted_at IS NULL
278 AND p.completion_eligible
279 AND NOT p.has_verified_student_number
280 AND ($1::timestamptz IS NULL OR cmc.completion_date >= $1)
281 AND ($2::uuid IS NULL OR cr.id = $2)
282ORDER BY route.enrolment_confirmed_at,
283 cr.id
284 "#,
285 since,
286 credit_registration_id,
287 )
288 .fetch_all(conn)
289 .await?;
290 Ok(res)
291}
292
293#[derive(Debug, Clone, PartialEq)]
295pub struct CreditRegistrationErrorCodeCount {
296 pub error_code: CreditRegistrationErrorCode,
297 pub live_count: i64,
299 pub in_flight_count: i64,
300 pub terminal_failure_count: i64,
301}
302
303pub async fn count_by_error_code(
305 conn: &mut PgConnection,
306) -> ModelResult<Vec<CreditRegistrationErrorCodeCount>> {
307 let rows = sqlx::query!(
308 r#"
309SELECT error_code AS "error_code!",
310 COUNT(*) AS "live_count!",
311 COUNT(*) FILTER (WHERE terminal_at IS NULL) AS "in_flight_count!",
312 COUNT(*) FILTER (
313 WHERE state = ANY($1::credit_registration_state [])
314 ) AS "terminal_failure_count!"
315FROM credit_registrations
316WHERE error_code IS NOT NULL
317 AND superseded_by_id IS NULL
318 AND deleted_at IS NULL
319GROUP BY error_code
320ORDER BY COUNT(*) DESC
321 "#,
322 &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
323 )
324 .fetch_all(conn)
325 .await?;
326 Ok(rows
327 .into_iter()
328 .map(|row| CreditRegistrationErrorCodeCount {
329 error_code: row.error_code,
330 live_count: row.live_count,
331 in_flight_count: row.in_flight_count,
332 terminal_failure_count: row.terminal_failure_count,
333 })
334 .collect())
335}
336
337#[derive(Debug, Clone, PartialEq)]
339pub struct OldestNonTerminalRegistration {
340 pub id: Uuid,
341 pub state: CreditRegistrationState,
342 pub state_changed_at: DateTime<Utc>,
343}
344
345pub async fn get_oldest_non_terminal(
346 conn: &mut PgConnection,
347) -> ModelResult<Option<OldestNonTerminalRegistration>> {
348 let row = sqlx::query_as!(
349 OldestNonTerminalRegistration,
350 r#"
351SELECT id,
352 state,
353 state_changed_at
354FROM credit_registrations
355WHERE terminal_at IS NULL
356 AND superseded_by_id IS NULL
357 AND deleted_at IS NULL
358ORDER BY state_changed_at
359LIMIT 1
360 "#,
361 )
362 .fetch_optional(conn)
363 .await?;
364 Ok(row)
365}
366
367#[derive(Debug, Clone, PartialEq)]
369pub struct CreditRegistrationThroughputDay {
370 pub day: DateTime<Utc>,
371 pub registered_count: i64,
372 pub other_success_count: i64,
373 pub failed_count: i64,
374}
375
376pub async fn get_throughput_by_day(
379 conn: &mut PgConnection,
380 since: DateTime<Utc>,
381) -> ModelResult<Vec<CreditRegistrationThroughputDay>> {
382 let rows = sqlx::query_as!(
383 CreditRegistrationThroughputDay,
384 r#"
385SELECT DATE_TRUNC('day', terminal_at) AS "day!",
386 COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
387 COUNT(*) FILTER (
388 WHERE state = ANY($2::credit_registration_state [])
389 ) AS "other_success_count!",
390 COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_count!"
391FROM credit_registrations
392WHERE terminal_at >= $1
393 AND superseded_by_id IS NULL
394 AND deleted_at IS NULL
395GROUP BY 1
396ORDER BY 1
397 "#,
398 since,
399 &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
400 )
401 .fetch_all(conn)
402 .await?;
403 Ok(rows)
404}
405
406#[derive(Debug, Clone, PartialEq, Default)]
408pub struct TerminalOutcomeTotals {
409 pub success_count: i64,
411 pub registered_count: i64,
413 pub failed_permanent_count: i64,
414 pub cancelled_count: i64,
415 pub total_count: i64,
417}
418
419pub async fn count_terminal_outcomes_since(
420 conn: &mut PgConnection,
421 since: DateTime<Utc>,
422) -> ModelResult<TerminalOutcomeTotals> {
423 let res = sqlx::query_as!(
424 TerminalOutcomeTotals,
425 r#"
426SELECT COUNT(*) FILTER (
427 WHERE state = ANY($2::credit_registration_state [])
428 ) AS "success_count!",
429 COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
430 COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_permanent_count!",
431 COUNT(*) FILTER (WHERE state = 'cancelled') AS "cancelled_count!",
432 COUNT(*) AS "total_count!"
433FROM credit_registrations
434WHERE terminal_at >= $1
435 AND superseded_by_id IS NULL
436 AND deleted_at IS NULL
437 "#,
438 since,
439 &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
440 )
441 .fetch_one(conn)
442 .await?;
443 Ok(res)
444}
445
446pub async fn count_entered_state_since(
449 conn: &mut PgConnection,
450 state: CreditRegistrationState,
451 since: DateTime<Utc>,
452) -> ModelResult<i64> {
453 let count = sqlx::query_scalar!(
454 r#"
455SELECT COUNT(*) AS "count!"
456FROM credit_registrations
457WHERE state = $1
458 AND state_changed_at >= $2
459 AND superseded_by_id IS NULL
460 AND deleted_at IS NULL
461 "#,
462 state as CreditRegistrationState,
463 since,
464 )
465 .fetch_one(conn)
466 .await?;
467 Ok(count)
468}
469
470#[derive(Debug, Clone, PartialEq)]
472pub struct RegistrationLatency {
473 pub registered_count: i64,
474 pub p50_end_to_end_secs: Option<i64>,
476 pub p95_end_to_end_secs: Option<i64>,
477 pub p50_confirmation_secs: Option<i64>,
480 pub p95_confirmation_secs: Option<i64>,
481}
482
483pub async fn get_registration_latency_between(
484 conn: &mut PgConnection,
485 from: DateTime<Utc>,
486 to: DateTime<Utc>,
487) -> ModelResult<RegistrationLatency> {
488 let res = sqlx::query_as!(
489 RegistrationLatency,
490 r#"
491SELECT COUNT(*) AS "registered_count!",
492 CEIL(
493 EXTRACT(
494 EPOCH
495 FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
496 ORDER BY terminal_at - created_at
497 )
498 )
499 )::bigint AS "p50_end_to_end_secs",
500 CEIL(
501 EXTRACT(
502 EPOCH
503 FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
504 ORDER BY terminal_at - created_at
505 )
506 )
507 )::bigint AS "p95_end_to_end_secs",
508 CEIL(
509 EXTRACT(
510 EPOCH
511 FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
512 ORDER BY registered_at - submitted_at
513 )
514 )
515 )::bigint AS "p50_confirmation_secs",
516 CEIL(
517 EXTRACT(
518 EPOCH
519 FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
520 ORDER BY registered_at - submitted_at
521 )
522 )
523 )::bigint AS "p95_confirmation_secs"
524FROM credit_registrations
525WHERE state = 'registered'
526 AND terminal_at >= $1
527 AND terminal_at < $2
528 AND superseded_by_id IS NULL
529 AND deleted_at IS NULL
530 "#,
531 from,
532 to,
533 )
534 .fetch_one(conn)
535 .await?;
536 Ok(res)
537}
538
539#[derive(Debug, Clone, PartialEq)]
541pub struct ModuleRegistrationTotals {
542 pub course_module_id: Uuid,
543 pub total_count: i64,
544 pub success_count: i64,
545 pub registered_count: i64,
547 pub already_in_sisu_count: i64,
549 pub in_flight_count: i64,
550 pub failed_count: i64,
551 pub last_registered_at: Option<DateTime<Utc>>,
552 pub top_error_code: Option<CreditRegistrationErrorCode>,
554}
555
556pub async fn count_by_module(
559 conn: &mut PgConnection,
560) -> ModelResult<Vec<ModuleRegistrationTotals>> {
561 let res = sqlx::query_as!(
562 ModuleRegistrationTotals,
563 r#"
564SELECT cr.course_module_id,
565 COUNT(*) AS "total_count!",
566 COUNT(*) FILTER (
567 WHERE cr.state = ANY($1::credit_registration_state [])
568 ) AS "success_count!",
569 COUNT(*) FILTER (WHERE cr.state = 'registered') AS "registered_count!",
570 COUNT(*) FILTER (
571 WHERE cr.state = ANY($3::credit_registration_state [])
572 ) AS "already_in_sisu_count!",
573 COUNT(*) FILTER (WHERE cr.terminal_at IS NULL) AS "in_flight_count!",
574 COUNT(*) FILTER (
575 WHERE cr.state = ANY($2::credit_registration_state [])
576 ) AS "failed_count!",
577 MAX(cr.registered_at) AS "last_registered_at",
578 (
579 SELECT inner_cr.error_code
580 FROM credit_registrations inner_cr
581 WHERE inner_cr.course_module_id = cr.course_module_id
582 AND inner_cr.error_code IS NOT NULL
583 AND inner_cr.superseded_by_id IS NULL
584 AND inner_cr.deleted_at IS NULL
585 GROUP BY inner_cr.error_code
586 ORDER BY COUNT(*) DESC,
587 inner_cr.error_code
588 LIMIT 1
589 ) AS "top_error_code?: CreditRegistrationErrorCode"
590FROM credit_registrations cr
591WHERE superseded_by_id IS NULL
592 AND deleted_at IS NULL
593GROUP BY cr.course_module_id
594 "#,
595 &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
596 &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
597 &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
598 )
599 .fetch_all(conn)
600 .await?;
601 Ok(res)
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ToSchema)]
609pub struct StuckThresholds {
610 pub stuck_ready_to_submit_secs: i64,
611 pub stuck_submitting_secs: i64,
612 pub stuck_awaiting_verification_secs: i64,
613 pub stuck_failed_retryable_secs: i64,
614}
615
616const SUOTAR_PENDING_WAIT: TimeDelta = TimeDelta::days(1);
618
619impl StuckThresholds {
620 pub const CURRENT: Self = Self {
622 stuck_ready_to_submit_secs: 2 * 60 * 60,
623 stuck_submitting_secs: 90 * 60,
624 stuck_awaiting_verification_secs: SUOTAR_PENDING_WAIT.num_seconds() + 2 * 60 * 60,
625 stuck_failed_retryable_secs: 3 * 24 * 60 * 60,
626 };
627
628 pub fn seconds_for(&self, state: CreditRegistrationState) -> Option<i64> {
630 let (states, seconds) = self.state_seconds_arrays();
631 states
632 .iter()
633 .position(|candidate| *candidate == state)
634 .map(|index| seconds[index] as i64)
635 }
636
637 pub(super) fn state_seconds_arrays(&self) -> ([CreditRegistrationState; 4], [f64; 4]) {
640 (
641 [
642 CreditRegistrationState::ReadyToSubmit,
643 CreditRegistrationState::Submitting,
644 CreditRegistrationState::AwaitingVerification,
645 CreditRegistrationState::FailedRetryable,
646 ],
647 [
648 self.stuck_ready_to_submit_secs as f64,
649 self.stuck_submitting_secs as f64,
650 self.stuck_awaiting_verification_secs as f64,
651 self.stuck_failed_retryable_secs as f64,
652 ],
653 )
654 }
655}
656
657const _: () = assert!(
658 StuckThresholds::CURRENT.stuck_failed_retryable_secs
659 < crate::library::credit_registration::backoff::SUBMIT_MAX_RETRY_AGE.num_seconds(),
660 "a row must be considered stuck before backoff gives up retrying it"
661);
662const _: () = assert!(
663 StuckThresholds::CURRENT.stuck_submitting_secs
664 > crate::library::credit_registration::backoff::SUBMITTING_RECOVERY_GRACE.num_seconds(),
665 "the stuck threshold must outlast the grace period that lets a submit recover on its own"
666);
667
668#[derive(Debug, Clone, PartialEq)]
669pub struct StuckRegistrationCount {
670 pub state: CreditRegistrationState,
671 pub count: i64,
672 pub severely_stuck_count: i64,
674 pub oldest_state_changed_at: Option<DateTime<Utc>>,
675}
676
677pub fn count_stuck_in_state(
680 rows: &[AttentionRegistration],
681 thresholds: &StuckThresholds,
682 now: DateTime<Utc>,
683) -> Vec<StuckRegistrationCount> {
684 let mut by_state: HashMap<CreditRegistrationState, StuckRegistrationCount> = HashMap::new();
685 for row in rows
686 .iter()
687 .filter(|row| row.reasons.contains(&AttentionReason::StuckInState))
688 {
689 let severe = thresholds
690 .seconds_for(row.state)
691 .is_some_and(|secs| (now - row.state_changed_at).num_seconds() > secs * 3);
692 let count = by_state.entry(row.state).or_insert(StuckRegistrationCount {
693 state: row.state,
694 count: 0,
695 severely_stuck_count: 0,
696 oldest_state_changed_at: None,
697 });
698 count.count += 1;
699 count.severely_stuck_count += i64::from(severe);
700 count.oldest_state_changed_at = Some(
701 count
702 .oldest_state_changed_at
703 .map_or(row.state_changed_at, |oldest| {
704 oldest.min(row.state_changed_at)
705 }),
706 );
707 }
708 let mut counts: Vec<StuckRegistrationCount> = by_state.into_values().collect();
709 counts.sort_by_key(|count| {
710 CreditRegistrationState::ALL
711 .iter()
712 .position(|state| *state == count.state)
713 });
714 counts
715}