@@ -14,7 +14,10 @@ use dioxus_i18n::t;
1414use futures_channel:: mpsc:: UnboundedReceiver ;
1515#[ cfg( target_arch = "wasm32" ) ]
1616use gloo_timers:: future:: TimeoutFuture ;
17- use std:: sync:: Arc ;
17+ use std:: sync:: {
18+ atomic:: { AtomicBool , AtomicU64 , Ordering } ,
19+ Arc ,
20+ } ;
1821
1922mod completed_exercises;
2023mod header;
@@ -488,15 +491,33 @@ pub fn GlobalSessionHeader() -> Element {
488491 . map ( |start| ( start, rd) )
489492 } ) ;
490493
491- // Track how many rest-exceeded intervals have fired for the current rest
492- // period. Reset to 0 each time a new rest period begins.
493- let mut rest_bell_count = use_signal ( || 0u64 ) ;
494+ // How many rest-exceeded intervals have fired for the current rest period.
495+ // Stored as an Arc<AtomicU64> so it can be read/written from both the
496+ // Dioxus thread and spawned async tasks without Signal's !Send constraint.
497+ let rest_bell_count = use_hook ( || Arc :: new ( AtomicU64 :: new ( 0 ) ) ) ;
498+ // Clone used by the tick coroutine (the scheduling effect takes the original).
499+ let bc_tick = rest_bell_count. clone ( ) ;
500+
501+ // Cancel token for the current one-shot scheduled notification.
502+ // Set to `true` to invalidate a pending spawn before it fires.
503+ let mut rest_cancel = use_signal ( || Arc :: new ( AtomicBool :: new ( false ) ) ) ;
504+
505+ // Memo tracking whether the session is currently paused.
506+ let session_paused_at = use_memo ( move || session ( ) . and_then ( |s| s. paused_at ) ) ;
494507
495508 // Pre-localise the notification strings in the reactive context so they
496509 // can be moved into async closures without requiring i18n context access.
497510 let rest_notif_title = use_memo ( move || t ! ( "notif-rest-title" ) . to_string ( ) ) ;
498511 let rest_notif_body = use_memo ( move || t ! ( "notif-rest-body" ) . to_string ( ) ) ;
499512
513+ // When the session is paused, cancel any pending one-shot notification so
514+ // it doesn't fire at a wall-clock time that ignores the pause duration.
515+ use_effect ( move || {
516+ if session_paused_at ( ) . is_some ( ) {
517+ rest_cancel. peek ( ) . store ( true , Ordering :: Relaxed ) ;
518+ }
519+ } ) ;
520+
500521 // Schedule a precise one-shot rest-over notification whenever a new rest
501522 // period begins. Fires ~250 ms early to compensate for jitter.
502523 use_effect ( move || {
@@ -507,7 +528,12 @@ pub fn GlobalSessionHeader() -> Element {
507528 return ;
508529 }
509530 // Reset the exceeded-interval counter for the new rest period.
510- rest_bell_count. set ( 0 ) ;
531+ rest_bell_count. store ( 0 , Ordering :: Relaxed ) ;
532+
533+ // Invalidate any previously scheduled notification and issue a new token.
534+ rest_cancel. peek ( ) . store ( true , Ordering :: Relaxed ) ;
535+ let cancel = Arc :: new ( AtomicBool :: new ( false ) ) ;
536+ rest_cancel. set ( cancel. clone ( ) ) ;
511537
512538 let title = rest_notif_title. peek ( ) . clone ( ) ;
513539 let body = rest_notif_body. peek ( ) . clone ( ) ;
@@ -519,11 +545,23 @@ pub fn GlobalSessionHeader() -> Element {
519545 if fire_at_secs > now {
520546 let delay_ms = ( ( fire_at_secs - now) * 1_000 )
521547 . saturating_sub ( crate :: components:: session_timers:: NOTIF_EARLY_MS ) ;
548+ let bc = rest_bell_count. clone ( ) ;
522549 tokio:: spawn ( async move {
523550 tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( delay_ms) ) . await ;
524- crate :: services:: notifications:: send_notification ( & title, & body, "logout-rest" ) ;
551+ if !cancel. load ( Ordering :: Relaxed ) {
552+ // Mark first interval done so the tick coroutine doesn't
553+ // fire a duplicate for interval 1.
554+ bc. store ( 1 , Ordering :: Relaxed ) ;
555+ crate :: services:: notifications:: send_notification (
556+ & title,
557+ & body,
558+ "logout-rest" ,
559+ ) ;
560+ }
525561 } ) ;
526562 } else {
563+ // Already past; send immediately and mark interval 1 done.
564+ rest_bell_count. store ( 1 , Ordering :: Relaxed ) ;
527565 crate :: services:: notifications:: send_notification ( & title, & body, "logout-rest" ) ;
528566 }
529567 }
@@ -537,35 +575,50 @@ pub fn GlobalSessionHeader() -> Element {
537575 } else {
538576 0
539577 } ;
578+ let bc = rest_bell_count. clone ( ) ;
540579 wasm_bindgen_futures:: spawn_local ( async move {
541580 gloo_timers:: future:: TimeoutFuture :: new ( delay_ms) . await ;
542- crate :: services:: notifications:: send_notification ( & title, & body, "logout-rest" ) ;
581+ if !cancel. load ( Ordering :: Relaxed ) {
582+ // Mark first interval done so the tick coroutine doesn't
583+ // fire a duplicate for interval 1.
584+ bc. store ( 1 , Ordering :: Relaxed ) ;
585+ crate :: services:: notifications:: send_notification ( & title, & body, "logout-rest" ) ;
586+ }
543587 } ) ;
544588 }
545589 } ) ;
546590
547591 // Tick-based coroutine: fires a notification for every completed exceeded
548592 // interval (2nd, 3rd, … ring) so the user keeps being reminded.
549- use_coroutine ( move |_: UnboundedReceiver < ( ) > | async move {
550- loop {
551- crate :: utils:: sleep_ms ( 1_000 ) . await ;
552- let Some ( ( start, duration) ) = * rest_key. peek ( ) else {
553- continue ;
554- } ;
555- if duration == 0 {
556- continue ;
557- }
558- let now = crate :: models:: get_current_timestamp ( ) ;
559- let elapsed = now. saturating_sub ( start) ;
560- let intervals = elapsed / duration;
561- let prev = * rest_bell_count. peek ( ) ;
562- if intervals > prev {
563- rest_bell_count. set ( intervals) ;
564- crate :: services:: notifications:: send_notification (
565- & rest_notif_title. peek ( ) ,
566- & rest_notif_body. peek ( ) ,
567- "logout-rest" ,
568- ) ;
593+ // Also handles the first notification on native (as a fallback).
594+ use_coroutine ( move |_: UnboundedReceiver < ( ) > | {
595+ // Clone inside the FnMut closure so each invocation gets its own Arc.
596+ let bc = bc_tick. clone ( ) ;
597+ async move {
598+ loop {
599+ crate :: utils:: sleep_ms ( 1_000 ) . await ;
600+ // Skip all checks while the session is paused.
601+ if session_paused_at. peek ( ) . is_some ( ) {
602+ continue ;
603+ }
604+ let Some ( ( start, duration) ) = * rest_key. peek ( ) else {
605+ continue ;
606+ } ;
607+ if duration == 0 {
608+ continue ;
609+ }
610+ let now = crate :: models:: get_current_timestamp ( ) ;
611+ let elapsed = now. saturating_sub ( start) ;
612+ let intervals = elapsed / duration;
613+ let prev = bc. load ( Ordering :: Relaxed ) ;
614+ if intervals > prev {
615+ bc. store ( intervals, Ordering :: Relaxed ) ;
616+ crate :: services:: notifications:: send_notification (
617+ & rest_notif_title. peek ( ) ,
618+ & rest_notif_body. peek ( ) ,
619+ "logout-rest" ,
620+ ) ;
621+ }
569622 }
570623 }
571624 } ) ;
0 commit comments