Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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); }
Expand Down
48 changes: 38 additions & 10 deletions src/components/active_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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);
});
}
Expand Down Expand Up @@ -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);
},
}
}
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/components/completed_exercise_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub fn CompletedExerciseLog(
idx: usize,
log: ExerciseLog,
session: Signal<WorkoutSession>,
on_replay: EventHandler<()>,
) -> Element {
let mut is_editing = use_signal(|| false);
let mut edit_weight_input = use_signal(String::new);
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/components/exercise_list.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::components::{ActiveTab, BottomNav, ExerciseCard};
use crate::services::{exercise_db, storage};
use crate::ExerciseFocusSignal;
use crate::Route;
use dioxus::prelude::*;

Expand All @@ -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::<ExerciseFocusSignal>().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 || {
Expand Down
21 changes: 20 additions & 1 deletion src/components/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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::<ExerciseFocusSignal>().0;

let duration = session
.end_time
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 20 additions & 28 deletions src/components/session_timers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,36 @@ 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!")
} else {
("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 ──────────────────────────────────────────────
Expand Down
Loading