@@ -12,6 +12,8 @@ use dioxus::prelude::*;
1212use dioxus_i18n:: prelude:: i18n;
1313use dioxus_i18n:: t;
1414use futures_channel:: mpsc:: UnboundedReceiver ;
15+ #[ cfg( target_arch = "wasm32" ) ]
16+ use gloo_timers:: future:: TimeoutFuture ;
1517use std:: sync:: Arc ;
1618
1719mod completed_exercises;
@@ -129,21 +131,6 @@ pub fn SessionView() -> Element {
129131 let pending_ids = use_memo ( move || session. read ( ) . pending_exercise_ids . clone ( ) ) ;
130132 let lang_str = use_memo ( move || i18n ( ) . language ( ) . to_string ( ) ) ;
131133 let mut notes_input = use_signal ( || session. read ( ) . notes . clone ( ) ) ;
132- // Initialise the uncontrolled textarea on first mount. Because we never
133- // bind `value:` to the textarea, the DOM starts blank even when the
134- // session already has notes (e.g. after reopening the app). This hook
135- // fires once and sets the initial DOM value via JavaScript.
136- let initial_notes = session. peek ( ) . notes . clone ( ) ;
137- use_hook ( move || {
138- if !initial_notes. is_empty ( ) {
139- let val_js = serde_json:: to_string ( & initial_notes) . unwrap_or_default ( ) ;
140- spawn ( async move {
141- document:: eval ( & format ! (
142- "var el=document.getElementById('session-notes-input');if(el)el.value={val_js};"
143- ) ) ;
144- } ) ;
145- }
146- } ) ;
147134 // Track the session ID so we can distinguish between:
148135 // (a) the debounce saving the user's own input for the *same* session
149136 // → do NOT touch the DOM (would reset cursor on Android)
@@ -384,7 +371,7 @@ pub fn SessionView() -> Element {
384371 }
385372 if !active_filters. read( ) . is_empty( ) {
386373 div { class: "filter-chips" ,
387- for ( i , filter) in active_filters. read( ) . iter( ) . enumerate( ) {
374+ for ( i, filter) in active_filters. read( ) . iter( ) . enumerate( ) {
388375 button {
389376 class: "filter-chip active" ,
390377 title: t!( "session-filter-remove" ) ,
@@ -459,9 +446,20 @@ pub fn SessionView() -> Element {
459446 // Dioxus to overwrite the DOM value on every re-render, which
460447 // resets the cursor position to the end on Android's WebView
461448 // (especially visible when typing fast with the IME).
462- // Instead we drive the initial / external value via eval in the
463- // `use_effect` above, and keep `notes_input` in sync via
464- // `oninput` so the effect can detect external changes.
449+ // Instead we set the initial DOM value via `onmounted` (fires
450+ // once the element is in the DOM) and keep `notes_input` in
451+ // sync via `oninput` so the effect can detect session changes.
452+ onmounted: move |_| {
453+ let notes = notes_input. peek( ) . clone( ) ;
454+ if !notes. is_empty( ) {
455+ let val_js = serde_json:: to_string( & notes) . unwrap_or_default( ) ;
456+ document:: eval(
457+ & format!(
458+ "var el=document.getElementById('session-notes-input');if(el)el.value={val_js};" ,
459+ ) ,
460+ ) ;
461+ }
462+ } ,
465463 oninput: move |evt| {
466464 let text = evt. value( ) ;
467465 notes_input. set( text. clone( ) ) ;
@@ -480,6 +478,98 @@ pub fn GlobalSessionHeader() -> Element {
480478 let rest_duration = use_context :: < RestDurationSignal > ( ) . 0 ;
481479 let mut rest_input_value = use_signal ( || DEFAULT_REST_SECONDS . to_string ( ) ) ;
482480 let mut congratulations = use_context :: < crate :: CongratulationsSignal > ( ) . 0 ;
481+
482+ // A memo that captures the (rest_start_time, rest_duration) pair so the
483+ // notification effect only re-fires when the rest period actually changes.
484+ let rest_key = use_memo ( move || {
485+ let rd = * rest_duration. read ( ) ;
486+ session ( )
487+ . and_then ( |s| s. rest_start_time )
488+ . map ( |start| ( start, rd) )
489+ } ) ;
490+
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+
495+ // Pre-localise the notification strings in the reactive context so they
496+ // can be moved into async closures without requiring i18n context access.
497+ let rest_notif_title = use_memo ( move || t ! ( "notif-rest-title" ) . to_string ( ) ) ;
498+ let rest_notif_body = use_memo ( move || t ! ( "notif-rest-body" ) . to_string ( ) ) ;
499+
500+ // Schedule a precise one-shot rest-over notification whenever a new rest
501+ // period begins. Fires ~250 ms early to compensate for jitter.
502+ use_effect ( move || {
503+ let Some ( ( start, duration) ) = rest_key ( ) else {
504+ return ;
505+ } ;
506+ if duration == 0 {
507+ return ;
508+ }
509+ // Reset the exceeded-interval counter for the new rest period.
510+ rest_bell_count. set ( 0 ) ;
511+
512+ let title = rest_notif_title. peek ( ) . clone ( ) ;
513+ let body = rest_notif_body. peek ( ) . clone ( ) ;
514+ let fire_at_secs = start + duration;
515+
516+ #[ cfg( not( target_arch = "wasm32" ) ) ]
517+ {
518+ let now = crate :: models:: get_current_timestamp ( ) ;
519+ if fire_at_secs > now {
520+ let delay_ms = ( ( fire_at_secs - now) * 1_000 )
521+ . saturating_sub ( crate :: components:: session_timers:: NOTIF_EARLY_MS ) ;
522+ tokio:: spawn ( async move {
523+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( delay_ms) ) . await ;
524+ crate :: services:: notifications:: send_notification ( & title, & body, "logout-rest" ) ;
525+ } ) ;
526+ } else {
527+ crate :: services:: notifications:: send_notification ( & title, & body, "logout-rest" ) ;
528+ }
529+ }
530+ #[ cfg( target_arch = "wasm32" ) ]
531+ {
532+ let now = crate :: models:: get_current_timestamp ( ) ;
533+ let delay_ms = if fire_at_secs > now {
534+ ( ( fire_at_secs - now) * 1_000 )
535+ . saturating_sub ( crate :: components:: session_timers:: NOTIF_EARLY_MS )
536+ . min ( u32:: MAX as u64 ) as u32
537+ } else {
538+ 0
539+ } ;
540+ wasm_bindgen_futures:: spawn_local ( async move {
541+ gloo_timers:: future:: TimeoutFuture :: new ( delay_ms) . await ;
542+ crate :: services:: notifications:: send_notification ( & title, & body, "logout-rest" ) ;
543+ } ) ;
544+ }
545+ } ) ;
546+
547+ // Tick-based coroutine: fires a notification for every completed exceeded
548+ // 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+ ) ;
569+ }
570+ }
571+ } ) ;
572+
483573 use_effect ( move || {
484574 if * show_rest. read ( ) {
485575 rest_input_value. set ( rest_duration. read ( ) . to_string ( ) ) ;
0 commit comments