From 3ef7951fd24bf5157f14807749609b178444875a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:46:28 +0000 Subject: [PATCH 1/3] Initial plan From deea764664594667350b499becee1ae0d7ccb6ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:05:50 +0000 Subject: [PATCH 2/3] feat: implement UX improvements per issue - replay button, notifications, exercise links, toast, CD filter Co-authored-by: gfauredev <19304085+gfauredev@users.noreply.github.com> --- .github/workflows/cd.yml | 12 +++++ assets/styles.css | 30 +++++++++++ src/components/active_session.rs | 48 +++++++++++++---- src/components/completed_exercise_log.rs | 7 +++ src/components/exercise_list.rs | 10 ++++ src/components/home.rs | 21 +++++++- src/components/session_timers.rs | 48 ++++++++--------- src/main.rs | 65 ++++++++++++++++++++++-- 8 files changed, 197 insertions(+), 44 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8f65268..c4a15ba 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,6 +16,12 @@ jobs: deploy-web: name: Deploy to GitHub Pages runs-on: ubuntu-latest + # On push to main, only run for feat:, fix:, or perf: commits + if: | + github.event_name != 'push' || + startsWith(github.event.head_commit.message, 'feat:') || + startsWith(github.event.head_commit.message, 'fix:') || + startsWith(github.event.head_commit.message, 'perf:') environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -55,6 +61,12 @@ jobs: build-android: name: Build Android APK (arm64) & AAB (all archs) runs-on: ubuntu-latest + # On push to main, only run for feat:, fix:, or perf: commits + if: | + github.event_name != 'push' || + startsWith(github.event.head_commit.message, 'feat:') || + startsWith(github.event.head_commit.message, 'fix:') || + startsWith(github.event.head_commit.message, 'perf:') steps: - uses: actions/checkout@v4 diff --git a/assets/styles.css b/assets/styles.css index fdffc1b..cd5a7cb 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -144,6 +144,13 @@ main { color: #667eea; border-radius: .8em; font-size: 0.85em; + border: none; + cursor: pointer; + transition: background 0.15s; +} + +.session-card__exercise-name:hover { + background: #252545; } .session-card__more { @@ -704,6 +711,22 @@ main { margin-top: 8px; } +.btn--replay-log { + background: none; + border: none; + cursor: pointer; + font-size: 1em; + padding: 2px 6px; + border-radius: .8em; + color: #43e97b; + opacity: 0.7; + transition: opacity 0.2s; +} + +.btn--replay-log:hover { + opacity: 1; +} + .btn--edit-log { background: none; border: none; @@ -884,10 +907,17 @@ main { font-weight: bold; font-size: 1.1em; z-index: 3000; + cursor: pointer; box-shadow: 0 4px 16px rgba(67, 233, 123, 0.4); animation: snackbar-in 0.3s ease-out; } +.snackbar--warning { + background: linear-gradient(135deg, #f6d365 0%, #fda085 100%); + color: #3a2000; + box-shadow: 0 4px 16px rgba(253, 160, 133, 0.4); +} + @keyframes snackbar-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } diff --git a/src/components/active_session.rs b/src/components/active_session.rs index 8efbea0..973c985 100644 --- a/src/components/active_session.rs +++ b/src/components/active_session.rs @@ -12,8 +12,6 @@ use super::session_timers::{RestTimerDisplay, SessionDurationDisplay}; /// Default rest duration in seconds const DEFAULT_REST_DURATION: u64 = 30; -/// Snackbar auto-dismiss delay in milliseconds -const SNACKBAR_DISMISS_MS: u32 = 3_000; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -257,15 +255,17 @@ pub fn SessionView() -> Element { #[cfg(target_arch = "wasm32")] { spawn(async move { - gloo_timers::future::TimeoutFuture::new(SNACKBAR_DISMISS_MS).await; + gloo_timers::future::TimeoutFuture::new(crate::SNACKBAR_DISMISS_MS).await; congratulations.set(false); }); } #[cfg(not(target_arch = "wasm32"))] { spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(SNACKBAR_DISMISS_MS as u64)) - .await; + tokio::time::sleep(std::time::Duration::from_millis( + crate::SNACKBAR_DISMISS_MS as u64, + )) + .await; congratulations.set(false); }); } @@ -479,11 +479,39 @@ pub fn SessionView() -> Element { section { h3 { "Completed Exercises" } for (idx, log) in session.read().exercise_logs.iter().enumerate().rev() { - CompletedExerciseLog { - key: "{idx}", - idx, - log: log.clone(), - session, + { + let ex_id = log.exercise_id.clone(); + rsx! { + CompletedExerciseLog { + key: "{idx}", + idx, + log: log.clone(), + session, + on_replay: move |_| { + prefill_inputs_from_last_log( + &ex_id, + weight_input, + reps_input, + distance_input, + ); + current_exercise_id.set(Some(ex_id.clone())); + let exercise_start = get_current_timestamp(); + current_exercise_start.set(Some(exercise_start)); + search_query.set(String::new()); + rest_start_time.set(None); + rest_bell_count.set(0); + duration_bell_rung.set(false); + let mut current_session = session.read().clone(); + current_session.rest_start_time = None; + current_session.current_exercise_id = + Some(ex_id.clone()); + current_session.current_exercise_start = + Some(exercise_start); + session.set(current_session.clone()); + storage::save_session(current_session); + }, + } + } } } } diff --git a/src/components/completed_exercise_log.rs b/src/components/completed_exercise_log.rs index d6f1a80..941ef54 100644 --- a/src/components/completed_exercise_log.rs +++ b/src/components/completed_exercise_log.rs @@ -10,6 +10,7 @@ pub fn CompletedExerciseLog( idx: usize, log: ExerciseLog, session: Signal, + on_replay: EventHandler<()>, ) -> Element { let mut is_editing = use_signal(|| false); let mut edit_weight_input = use_signal(String::new); @@ -68,6 +69,12 @@ pub fn CompletedExerciseLog( class: "completed-log__header", h4 { class: "completed-log__title", "{log.exercise_name}" } div { class: "completed-log__actions", + button { + class: "btn--replay-log", + title: "Do another set", + onclick: move |_| on_replay.call(()), + "▶" + } button { class: "btn--edit-log", onclick: start_edit, diff --git a/src/components/exercise_list.rs b/src/components/exercise_list.rs index 19a429b..300b5d6 100644 --- a/src/components/exercise_list.rs +++ b/src/components/exercise_list.rs @@ -1,6 +1,7 @@ use crate::components::{ActiveTab, BottomNav, ExerciseCard}; use crate::services::{exercise_db, storage}; use crate::Route; +use crate::ExerciseFocusSignal; use dioxus::prelude::*; /// Number of exercises loaded per scroll increment. @@ -13,6 +14,15 @@ pub fn ExerciseListPage() -> Element { let sessions = storage::use_sessions(); let mut search_query = use_signal(String::new); let mut visible_count = use_signal(|| PAGE_SIZE); + let mut exercise_focus = use_context::().0; + + // Pre-fill search from focus signal set by exercise tag click in past sessions + use_effect(move || { + if let Some(name) = exercise_focus.write().take() { + search_query.set(name); + visible_count.set(PAGE_SIZE); + } + }); // Collect exercise IDs from the active session (if any) let active_session_ids = use_memo(move || { diff --git a/src/components/home.rs b/src/components/home.rs index 16de9d1..e937869 100644 --- a/src/components/home.rs +++ b/src/components/home.rs @@ -2,6 +2,7 @@ use crate::components::{ActiveTab, BottomNav, SessionView}; use crate::models::{format_time, WorkoutSession}; use crate::services::storage; use crate::utils::format_session_date; +use crate::{ExerciseFocusSignal, Route}; use dioxus::prelude::*; #[component] @@ -65,6 +66,8 @@ fn SessionCard(session: WorkoutSession) -> Element { let mut show_delete_confirm = use_signal(|| false); let mut show_all_exercises = use_signal(|| false); let session_id = session.id.clone(); + let navigator = use_navigator(); + let mut exercise_focus = use_context::().0; let duration = session .end_time @@ -139,7 +142,23 @@ fn SessionCard(session: WorkoutSession) -> Element { if !unique_exercises.is_empty() { div { class: "session-card__exercises", for (_, name) in unique_exercises.iter().take(visible_count) { - span { class: "session-card__exercise-name", "{name}" } + { + let name = name.clone(); + rsx! { + button { + class: "session-card__exercise-name", + title: "Find in Exercise List", + onclick: { + let name = name.clone(); + move |_| { + *exercise_focus.write() = Some(name.clone()); + navigator.push(Route::ExerciseListPage {}); + } + }, + "{name}" + } + } + } } if hidden_count > 0 { button { diff --git a/src/components/session_timers.rs b/src/components/session_timers.rs index bac5d41..ad65df8 100644 --- a/src/components/session_timers.rs +++ b/src/components/session_timers.rs @@ -5,12 +5,18 @@ use dioxus::prelude::*; #[cfg(target_arch = "wasm32")] const TIMER_TICK_MS: u32 = 1_000; -/// Send a notification using the Web Notifications API. -/// The system decides whether to play audio or vibrate. +/// Send a notification using the service worker Notifications API. +/// Using `registration.showNotification()` instead of `new Notification()` ensures +/// notifications work on Android PWAs where the constructor is not available. +/// Falls back to `new Notification()` if the service worker is not ready. /// `is_duration_bell` selects a different message to distinguish from rest alerts. #[cfg(target_arch = "wasm32")] pub(super) fn send_notification(is_duration_bell: bool) { - use web_sys::{Notification, NotificationOptions, NotificationPermission}; + use web_sys::{Notification, NotificationPermission}; + + if Notification::permission() != NotificationPermission::Granted { + return; + } let (title, body) = if is_duration_bell { ("Duration reached", "Target exercise duration reached!") @@ -18,31 +24,17 @@ pub(super) fn send_notification(is_duration_bell: bool) { ("Rest over", "Time to start your next set!") }; - let send = |t: &str, b: &str| { - let opts = NotificationOptions::new(); - opts.set_body(b); - let _ = Notification::new_with_options(t, &opts); - }; - - match Notification::permission() { - NotificationPermission::Granted => send(title, body), - NotificationPermission::Default => { - let title = title.to_string(); - let body = body.to_string(); - if let Ok(promise) = Notification::request_permission() { - wasm_bindgen_futures::spawn_local(async move { - if wasm_bindgen_futures::JsFuture::from(promise).await.is_ok() - && Notification::permission() == NotificationPermission::Granted - { - let opts = NotificationOptions::new(); - opts.set_body(&body); - let _ = Notification::new_with_options(&title, &opts); - } - }); - } - } - _ => {} - } + // Prefer service worker showNotification for PWA/mobile (Android) compatibility. + // new Notification() is not supported in Android PWAs; service worker must be used. + // Use JSON-encoded strings to safely handle any special characters. + let title_json = serde_json::to_string(title).unwrap_or_default(); + let body_json = serde_json::to_string(body).unwrap_or_default(); + let script = format!( + "(async()=>{{try{{const r=await navigator.serviceWorker.ready;\ + r.showNotification({title_json},{{body:{body_json},requireInteraction:false}});\ + }}catch(e){{new Notification({title_json},{{body:{body_json}}})}}}}})();" + ); + let _ = js_sys::eval(&script); } // ── Isolated timer components ────────────────────────────────────────────── diff --git a/src/main.rs b/src/main.rs index dc6e6a7..92a9ba7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,10 +10,21 @@ use components::{ HomePage, }; +/// Snackbar auto-dismiss delay in milliseconds +const SNACKBAR_DISMISS_MS: u32 = 3_000; + /// Global context signal for the congratulations toast shown after completing a session. #[derive(Clone, Copy)] pub struct CongratulationsSignal(pub Signal); +/// Global context signal for the notification permission warning toast. +#[derive(Clone, Copy)] +pub struct NotificationWarningSignal(pub Signal); + +/// Global context signal for focusing an exercise in the exercise list (by name). +#[derive(Clone, Copy)] +pub struct ExerciseFocusSignal(pub Signal>); + #[derive(Clone, Routable, Debug, PartialEq)] #[rustfmt::skip] #[allow(clippy::enum_variant_names)] @@ -60,27 +71,71 @@ fn App() -> Element { services::storage::provide_app_state(); services::exercise_db::provide_exercises(); use_context_provider(|| CongratulationsSignal(Signal::new(false))); + use_context_provider(|| NotificationWarningSignal(Signal::new(false))); + use_context_provider(|| ExerciseFocusSignal(Signal::new(None))); + + // Show a warning toast if notification permission is denied on app start + #[cfg(all(target_arch = "wasm32", feature = "web-platform"))] + { + let mut warn = use_context::().0; + use_effect(move || { + use web_sys::{Notification, NotificationPermission}; + if Notification::permission() == NotificationPermission::Denied { + warn.set(true); + spawn(async move { + gloo_timers::future::TimeoutFuture::new(SNACKBAR_DISMISS_MS).await; + warn.set(false); + }); + } + }); + } rsx! { Stylesheet { href: asset!("/assets/styles.css") } Router:: {} CongratulationsToast {} + NotificationWarningToast {} } } /// Renders the congratulations toast when a session is successfully completed. #[component] fn CongratulationsToast() -> Element { - let mut show = use_context::().0; + let show = use_context::().0; + if *show.read() { + rsx! { Snackbar { text: "🎉 Great workout! Session complete!", signal: show } } + } else { + rsx! {} + } +} + +/// Renders a warning toast when notification permission is denied. +#[component] +fn NotificationWarningToast() -> Element { + let show = use_context::().0; if *show.read() { rsx! { - div { - class: "snackbar", - onclick: move |_| show.set(false), - "🎉 Great workout! Session complete!" + Snackbar { + text: "⚠️ Notifications blocked – rest alerts won't be shown", + signal: show, + warning: true, } } } else { rsx! {} } } + +/// Common snackbar/toast component. Dismisses on click. +/// Auto-dismiss is handled by the caller via the shared signal. +#[component] +fn Snackbar(text: &'static str, signal: Signal, #[props(default)] warning: bool) -> Element { + let mut sig = signal; + rsx! { + div { + class: if warning { "snackbar snackbar--warning" } else { "snackbar" }, + onclick: move |_| sig.set(false), + "{text}" + } + } +} From e16b133f5294e2ad24fea6bf08a38b2ba31fc882 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:27:44 +0000 Subject: [PATCH 3/3] fix: run cargo fmt to fix import ordering in exercise_list.rs Co-authored-by: gfauredev <19304085+gfauredev@users.noreply.github.com> --- src/components/exercise_list.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/exercise_list.rs b/src/components/exercise_list.rs index 300b5d6..fc18911 100644 --- a/src/components/exercise_list.rs +++ b/src/components/exercise_list.rs @@ -1,7 +1,7 @@ use crate::components::{ActiveTab, BottomNav, ExerciseCard}; use crate::services::{exercise_db, storage}; -use crate::Route; use crate::ExerciseFocusSignal; +use crate::Route; use dioxus::prelude::*; /// Number of exercises loaded per scroll increment.