Skip to content

Commit b209626

Browse files
Copilotgfauredev
andcommitted
fix: cancellable/pause-aware rest notifications, no double-fire, resumed session UI, stale ATH notification
Agent-Logs-Url: https://github.com/gfauredev/LogOut/sessions/feeac486-69ef-4414-beff-795baf8bb24b Co-authored-by: gfauredev <19304085+gfauredev@users.noreply.github.com>
1 parent 18f6fe9 commit b209626

2 files changed

Lines changed: 104 additions & 28 deletions

File tree

src/components/active_session/mod.rs

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ use dioxus_i18n::t;
1414
use futures_channel::mpsc::UnboundedReceiver;
1515
#[cfg(target_arch = "wasm32")]
1616
use gloo_timers::future::TimeoutFuture;
17-
use std::sync::Arc;
17+
use std::sync::{
18+
atomic::{AtomicBool, AtomicU64, Ordering},
19+
Arc,
20+
};
1821

1922
mod completed_exercises;
2023
mod 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
});

src/components/home.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,28 @@ pub fn Home() -> Element {
6666
.filter(|s| !s.is_active() && !viewed_ids.contains(&s.id))
6767
.cloned()
6868
.collect();
69-
if !newly_completed.is_empty() {
69+
70+
// Sessions that became active again (e.g. resumed from completed state).
71+
let active_ids: std::collections::HashSet<String> = sessions
72+
.peek()
73+
.iter()
74+
.filter(|s| s.is_active())
75+
.map(|s| s.id.clone())
76+
.collect();
77+
let has_resumed = !active_ids.is_empty()
78+
&& completed_sessions
79+
.peek()
80+
.iter()
81+
.any(|s| active_ids.contains(&s.id));
82+
83+
if !newly_completed.is_empty() || has_resumed {
7084
newly_completed.sort_by(|a, b| b.start_time.cmp(&a.start_time));
7185
let new_len = {
7286
let mut cs = completed_sessions.write();
87+
// Remove sessions that have been re-activated.
88+
if has_resumed {
89+
cs.retain(|s| !active_ids.contains(&s.id));
90+
}
7391
let mut new_cs = Vec::with_capacity(newly_completed.len() + cs.len());
7492
new_cs.extend(newly_completed);
7593
new_cs.extend(cs.drain(..));
@@ -157,6 +175,11 @@ pub fn Home() -> Element {
157175
let mut s = last_sess.clone();
158176
s.end_time = None;
159177
s.paused_at = None;
178+
// Clear transient fields that are stale after the
179+
// session was completed (rest/exercise timers).
180+
s.rest_start_time = None;
181+
s.current_exercise_id = None;
182+
s.current_exercise_start = None;
160183
s
161184
};
162185
rsx! {

0 commit comments

Comments
 (0)