diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8f65268..e78845e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,6 +16,14 @@ jobs: deploy-web: name: Deploy to GitHub Pages runs-on: ubuntu-latest + if: >- + github.event_name != 'push' || + startsWith(github.event.head_commit.message, 'feat:') || + startsWith(github.event.head_commit.message, 'feat(') || + startsWith(github.event.head_commit.message, 'fix:') || + startsWith(github.event.head_commit.message, 'fix(') || + startsWith(github.event.head_commit.message, 'perf:') || + startsWith(github.event.head_commit.message, 'perf(') environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -55,6 +63,14 @@ jobs: build-android: name: Build Android APK (arm64) & AAB (all archs) runs-on: ubuntu-latest + if: >- + github.event_name != 'push' || + startsWith(github.event.head_commit.message, 'feat:') || + startsWith(github.event.head_commit.message, 'feat(') || + startsWith(github.event.head_commit.message, 'fix:') || + startsWith(github.event.head_commit.message, 'fix(') || + startsWith(github.event.head_commit.message, 'perf:') || + startsWith(github.event.head_commit.message, 'perf(') steps: - uses: actions/checkout@v4 diff --git a/Cargo.toml b/Cargo.toml index 1cb61de..96d6db3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json"] } web-sys = { version = "0.3", features = [ "Window", "Storage", "Navigator", "ServiceWorkerContainer", "ServiceWorkerRegistration", - "Document", "EventTarget", "VisibilityState", + "Document", "Element", "EventTarget", "VisibilityState", "Notification", "NotificationPermission", "NotificationOptions", ] } wasm-bindgen = "0.2" diff --git a/assets/styles.css b/assets/styles.css index fdffc1b..04940c8 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -1,3 +1,49 @@ +/* ===== Design Tokens ===== */ +:root { + /* ── Colour palette ── */ + --color-primary: #667eea; + --color-secondary: #764ba2; + --color-accent: #4facfe; + --color-accent-light: #00f2fe; + --color-danger: #e74c3c; + --color-success: #43e97b; + --color-success-light: #38f9d7; + --color-pink: #f093fb; + --color-pink-dark: #f5576c; + + /* ── Gradients ── */ + --gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%); + --gradient-accent: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-light) 100%); + --gradient-success: linear-gradient(135deg, var(--color-success) 0%, var(--color-success-light) 100%); + --gradient-pink: linear-gradient(135deg, var(--color-pink) 0%, var(--color-pink-dark) 100%); + + /* ── Surfaces ── */ + --bg-body: #000; + --bg-card: #111; + --bg-elevated: #1a1a2e; + --bg-muted: #222; + + /* ── Borders ── */ + --border-color: #333; + + /* ── Text ── */ + --text-primary: #fff; + --text-secondary: #ccc; + --text-muted: #999; + --text-dim: #777; + --text-faint: #aaa; + --text-soft: #e0e0e0; + + /* ── Radius ── */ + --radius: .8em; + + /* ── Muscles (tag colours) ── */ + --muscle-primary-bg: #2d7a3a; + --muscle-primary-fg: #c8f7d0; + --muscle-secondary-bg: #1a4a4a; + --muscle-secondary-fg: #a8edea; +} + main > header, #main > header { margin-top: 1em; margin-bottom: 1em; @@ -5,7 +51,7 @@ main > header, #main > header { } h1 { - color: #fff; + color: var(--text-primary); } /* ===== Global / Shared ===== */ @@ -53,7 +99,7 @@ main { /* ===== Sessions Tab (center tab home page) ===== */ .sessions > p { text-align: center; - color: #777; + color: var(--text-dim); margin-top: 60px; font-size: 1.1em; } @@ -67,9 +113,9 @@ main { /* ===== Session Card ===== */ .session-card { padding: 16px; - border: 1px solid #333; - border-radius: .8em; - background: #111; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-card); box-shadow: 0 2px 6px rgba(0,0,0,0.3); } @@ -82,7 +128,7 @@ main { .session-card__date { font-size: 0.85em; - color: #999; + color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; @@ -95,7 +141,7 @@ main { cursor: pointer; font-size: 1.1em; padding: 4px 8px; - border-radius: .8em; + border-radius: var(--radius); opacity: 0.6; transition: opacity 0.2s; } @@ -116,7 +162,7 @@ main { cursor: pointer; font-size: 1.1em; padding: 4px 8px; - border-radius: .8em; + border-radius: var(--radius); opacity: 0.6; transition: opacity 0.2s; } @@ -128,7 +174,7 @@ main { .session-card__stat { font-size: 0.9em; font-weight: 600; - color: #ccc; + color: var(--text-secondary); white-space: nowrap; } @@ -140,25 +186,25 @@ main { .session-card__exercise-name { padding: 3px 10px; - background: #1a1a2e; - color: #667eea; - border-radius: .8em; + background: var(--bg-elevated); + color: var(--color-primary); + border-radius: var(--radius); font-size: 0.85em; } .session-card__more { padding: 3px 10px; - background: #222; - color: #777; - border-radius: .8em; + background: var(--bg-muted); + color: var(--text-dim); + border-radius: var(--radius); font-size: 0.85em; border: none; cursor: pointer; } .session-card__more:hover { - background: #333; - color: #aaa; + background: var(--border-color); + color: var(--text-faint); } /* ===== Delete Confirmation Modal ===== */ @@ -175,8 +221,8 @@ main { left: 50%; transform: translate(-50%, -50%); background: #1a1a1a; - border: 1px solid #333; - border-radius: .8em; + border: 1px solid var(--border-color); + border-radius: var(--radius); padding: 24px; text-align: center; min-width: 260px; @@ -196,10 +242,10 @@ main { .btn--danger { padding: 10px 20px; - background: #e74c3c; - color: #fff; + background: var(--color-danger); + color: var(--text-primary); border: none; - border-radius: .8em; + border-radius: var(--radius); font-weight: bold; cursor: pointer; } @@ -209,9 +255,9 @@ main { display: block; width: 56px; height: 56px; - border-radius: .8em; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: #fff; + border-radius: var(--radius); + background: var(--gradient-primary); + color: var(--text-primary); font-size: 2em; border: none; cursor: pointer; @@ -226,8 +272,8 @@ main { bottom: 0; margin: auto 0 0 0; height: max-contents; - background: #111; - border-top: 1px solid #333; + background: var(--bg-card); + border-top: 1px solid var(--border-color); display: flex; justify-content: space-around; align-items: stretch; @@ -271,11 +317,11 @@ main { .form-input, .search-input, .form-select, .muscle-select { width: 100%; padding: .6em; - border: 1px solid #333; - border-radius: .8em; + border: 1px solid var(--border-color); + border-radius: var(--radius); font-size: 16px; box-sizing: border-box; - background: #111; + background: var(--bg-card); color: inherit; margin-bottom: 1em; } @@ -283,7 +329,7 @@ main { flex: 1; } .form-input--invalid { - border-color: #e74c3c; + border-color: var(--color-danger); } .form-input--rest { width: 80px; @@ -296,7 +342,7 @@ main { .form-select--chart { border-width: 2px; font-size: 14px; - background: #111; + background: var(--bg-card); cursor: pointer; } .muscle-select { @@ -310,10 +356,10 @@ main { width: 42px; height: 42px; flex-shrink: 0; - background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); - color: #fff; + background: var(--gradient-pink); + color: var(--text-primary); text-decoration: none; - border-radius: .8em; + border-radius: var(--radius); font-size: 1.6em; font-weight: bold; line-height: 1; @@ -330,9 +376,9 @@ main { .exercise-card { padding: 15px; - border: 1px solid #333; - border-radius: .8em; - background: #111; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-card); box-shadow: 0 2px 4px rgba(0,0,0,0.2); } @@ -340,19 +386,19 @@ main { margin: 0 0 8px 0; font-size: 1.2em; cursor: pointer; - color: #fff; + color: var(--text-primary); } .exercise-card__title:hover { - color: #667eea; + color: var(--color-primary); } .exercise-card__custom-badge { display: inline-block; padding: 2px 10px; - background: #764ba2; - color: #fff; - border-radius: .8em; + background: var(--color-secondary); + color: var(--text-primary); + border-radius: var(--radius); font-size: 0.75em; font-weight: bold; text-transform: uppercase; @@ -364,7 +410,7 @@ main { width: auto; max-width: 100%; max-height: 20vh; - border-radius: .8em; + border-radius: var(--radius); margin: 0 auto 10px auto; cursor: pointer; } @@ -378,37 +424,37 @@ main { .tag { padding: 3px 8px; - color: #fff; - border-radius: .8em; + color: var(--text-primary); + border-radius: var(--radius); font-size: 0.8em; } -.tag--category { background: #667eea; } -.tag--force { background: #764ba2; } -.tag--equipment { background: #4facfe; } -.tag--level { background: #f5576c; } -.tag--muscle-primary { background: #2d7a3a; color: #c8f7d0; } -.tag--muscle-secondary { background: #1a4a4a; color: #a8edea; } +.tag--category { background: var(--color-primary); } +.tag--force { background: var(--color-secondary); } +.tag--equipment { background: var(--color-accent); } +.tag--level { background: var(--color-pink-dark); } +.tag--muscle-primary { background: var(--muscle-primary-bg); color: var(--muscle-primary-fg); } +.tag--muscle-secondary { background: var(--muscle-secondary-bg); color: var(--muscle-secondary-fg); } .exercise-card__instructions { margin: 10px 0; padding-left: 20px; - color: #aaa; + color: var(--text-faint); font-size: 0.9em; line-height: 1.5; } .exercise-card__muscles { - color: #999; + color: var(--text-muted); font-size: 0.9em; } /* ===== Search Results Dropdown ===== */ .search-results { margin-top: 10px; - border: 1px solid #333; - border-radius: .8em; - background: #111; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-card); max-height: 200px; overflow-y: auto; } @@ -420,7 +466,7 @@ main { .search-result-item { padding: 10px; cursor: pointer; - border-bottom: 1px solid #222; + border-bottom: 1px solid var(--bg-muted); } .search-result-item--flex { @@ -431,7 +477,7 @@ main { } .search-result-item:hover { - background: #1a1a2e; + background: var(--bg-elevated); } /* ===== Form Controls ===== */ @@ -449,42 +495,42 @@ main { display: block; margin-bottom: 5px; font-weight: bold; - color: #ccc; + color: var(--text-secondary); } .form-label--color { - color: #ccc; + color: var(--text-secondary); margin-bottom: 8px; } .input-small { padding: 8px; - border: 1px solid #333; - border-radius: .8em; + border: 1px solid var(--border-color); + border-radius: var(--radius); width: 80px; - background: #111; + background: var(--bg-card); } .input-medium { padding: 8px; - border: 1px solid #333; - border-radius: .8em; + border: 1px solid var(--border-color); + border-radius: var(--radius); width: 100px; - background: #111; + background: var(--bg-card); } /* ===== Buttons ===== */ .btn { border: none; - border-radius: .8em; + border-radius: var(--radius); font-weight: bold; cursor: pointer; } .btn--primary { padding: 15px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: #fff; + background: var(--gradient-primary); + color: var(--text-primary); font-size: 1.2em; width: 100%; margin-top: 10px; @@ -492,20 +538,20 @@ main { .btn--accent { padding: 8px 16px; - background: #4facfe; - color: #fff; + background: var(--color-accent); + color: var(--text-primary); border: none; - border-radius: .8em; + border-radius: var(--radius); font-weight: bold; cursor: pointer; } .btn--accent-lg { padding: 10px 20px; - background: #4facfe; - color: #fff; + background: var(--color-accent); + color: var(--text-primary); border: none; - border-radius: .8em; + border-radius: var(--radius); font-weight: bold; cursor: pointer; } @@ -513,10 +559,10 @@ main { .btn--complete { flex: 1; padding: 15px; - background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); - color: #fff; + background: var(--gradient-accent); + color: var(--text-primary); border: none; - border-radius: .8em; + border-radius: var(--radius); font-size: 1.1em; font-weight: bold; cursor: pointer; @@ -524,10 +570,10 @@ main { .btn--cancel { padding: 15px 25px; - background: #333; - color: #ccc; + background: var(--border-color); + color: var(--text-secondary); border: none; - border-radius: .8em; + border-radius: var(--radius); font-size: 1.1em; font-weight: bold; cursor: pointer; @@ -535,10 +581,10 @@ main { .btn--finish { padding: 12px 24px; - background: #1a1a2e; - color: #667eea; + background: var(--bg-elevated); + color: var(--color-primary); border: none; - border-radius: .8em; + border-radius: var(--radius); font-size: 1.1em; font-weight: bold; cursor: pointer; @@ -548,9 +594,9 @@ main { .btn--cancel-session { padding: 12px 24px; background: #2a1a1a; - color: #e74c3c; + color: var(--color-danger); border: none; - border-radius: .8em; + border-radius: var(--radius); font-size: 1.1em; font-weight: bold; cursor: pointer; @@ -571,20 +617,20 @@ main { .set-line { padding: 5px 0; - color: #999; + color: var(--text-muted); } .workout-exercise-card { padding: 15px; margin-bottom: 15px; - border: 1px solid #333; - border-radius: .8em; - background: #111; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-card); } .workout-exercise-card__title { margin: 0 0 10px 0; - color: #fff; + color: var(--text-primary); } /* ===== Active Session ===== */ @@ -594,13 +640,13 @@ main { height: 100dvh; position: relative; margin: 0 auto; - color: #e0e0e0; + color: var(--text-soft); } .session-header { position: sticky; top: 0; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--gradient-primary); color: white; padding: 15px 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.3); @@ -644,8 +690,8 @@ main { .exercise-form { padding: 20px; - border: 2px solid #667eea; - border-radius: .8em; + border: 2px solid var(--color-primary); + border-radius: var(--radius); background: #0a0a1e; margin-bottom: 20px; position: relative; @@ -658,7 +704,7 @@ main { .exercise-form__title { margin-top: 0; - color: #fff; + color: var(--text-primary); } .exercise-form__last-duration { @@ -674,9 +720,9 @@ main { .completed-log { padding: 15px; margin-bottom: 10px; - border: 1px solid #333; - border-radius: .8em; - background: #111; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-card); } .completed-log__header { @@ -688,7 +734,7 @@ main { .completed-log__title { margin: 0; - color: #fff; + color: var(--text-primary); } .completed-log__actions { @@ -710,8 +756,8 @@ main { cursor: pointer; font-size: 1em; padding: 2px 6px; - border-radius: .8em; - color: #667eea; + border-radius: var(--radius); + color: var(--color-primary); opacity: 0.7; transition: opacity 0.2s; } @@ -726,7 +772,7 @@ main { cursor: pointer; font-size: 1em; padding: 2px 6px; - border-radius: .8em; + border-radius: var(--radius); opacity: 0.6; transition: opacity 0.2s; } @@ -740,14 +786,14 @@ main { margin-bottom: 20px; padding: 14px; border: 1px dashed #4a4a6a; - border-radius: .8em; + border-radius: var(--radius); background: #0a0a1a; } .pending-exercises h3 { margin: 0 0 12px 0; font-size: 1em; - color: #999; + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; } @@ -757,7 +803,7 @@ main { align-items: center; gap: 10px; padding: 10px 0; - border-bottom: 1px solid #222; + border-bottom: 1px solid var(--bg-muted); } .pending-exercise-item:last-child { @@ -768,15 +814,15 @@ main { .pending-exercise-item__name { flex: 1; font-weight: 500; - color: #ccc; + color: var(--text-secondary); } .btn--start { padding: 7px 14px; - background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); + background: var(--gradient-success); color: #1a3a1a; border: none; - border-radius: .8em; + border-radius: var(--radius); font-weight: bold; font-size: 0.9em; cursor: pointer; @@ -793,13 +839,13 @@ main { padding: 10px 16px; font-size: 2em; font-weight: bold; - color: #ccc; + color: var(--text-secondary); letter-spacing: 0.05em; margin-bottom: 12px; } .exercise-static-timer--reached { - color: #43e97b; + color: var(--color-success); } /* ===== Custom Exercise Card Header ===== */ @@ -812,29 +858,29 @@ main { .exercise-card__edit-btn { font-size: 0.85em; - color: #667eea; + color: var(--color-primary); text-decoration: none; padding: 2px 8px; - border-radius: .8em; - border: 1px solid #667eea; + border-radius: var(--radius); + border: 1px solid var(--color-primary); } .exercise-card__edit-btn:hover { - background: #1a1a2e; + background: var(--bg-elevated); } .completed-log__details { - color: #999; + color: var(--text-muted); font-size: 0.9em; } .add-custom-link { display: inline-block; padding: 12px 24px; - background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + background: var(--gradient-pink); color: white; text-decoration: none; - border-radius: .8em; + border-radius: var(--radius); font-weight: bold; } @@ -850,13 +896,13 @@ main { font-size: 1.2em; font-weight: bold; background: #0a1a0a; - color: #43e97b; - border-bottom: 1px solid #333; + color: var(--color-success); + border-bottom: 1px solid var(--border-color); } .rest-timer--exceeded { background: #1a0a0a; - color: #e74c3c; + color: var(--color-danger); } /* ===== Rest Duration Input ===== */ @@ -865,9 +911,9 @@ main { align-items: center; gap: 10px; padding: 10px 20px; - background: #111; - border-bottom: 1px solid #333; - color: #ccc; + background: var(--bg-card); + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); font-size: 0.9em; } @@ -877,10 +923,10 @@ main { bottom: 80px; left: 50%; transform: translateX(-50%); - background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); + background: var(--gradient-success); color: #1a3a1a; padding: 14px 28px; - border-radius: .8em; + border-radius: var(--radius); font-weight: bold; font-size: 1.1em; z-index: 3000; @@ -897,7 +943,7 @@ main { .instructions-list { margin: 10px 0 0 0; padding-left: 20px; - color: #ccc; + color: var(--text-secondary); font-size: 0.9em; line-height: 1.6; } @@ -910,31 +956,31 @@ main { } /* ===== Analytics ===== */ -.analytics .chart-svg { +.analytics-panel .chart-svg { display: block; } -.analytics { +.analytics-panel { height: 100%; display: flex; flex-direction: column; - background: #000; + background: var(--bg-body); overflow-y: auto; } -.analytics__header { +.analytics-panel__header { padding: 20px; - border-bottom: 1px solid #333; - color: #e0e0e0; + border-bottom: 1px solid var(--border-color); + color: var(--text-soft); text-align: center; } -.analytics .controls { +.analytics-panel .controls { padding: 20px; - border-bottom: 1px solid #333; + border-bottom: 1px solid var(--border-color); } -.analytics .chart { +.analytics-panel .chart { flex: 1; padding: 20px; min-height: 300px; @@ -945,7 +991,7 @@ main { align-items: center; justify-content: center; height: 300px; - color: #777; + color: var(--text-dim); text-align: center; } @@ -964,7 +1010,7 @@ main { .color-dot { width: 20px; height: 20px; - border-radius: .8em; + border-radius: var(--radius); flex-shrink: 0; } @@ -975,14 +1021,14 @@ main { /* ===== Header / Navigation (used by exercise list and other pages) ===== */ .back-link { text-decoration: none; - color: #667eea; + color: var(--color-primary); font-size: 1.1em; } .back-btn { background: none; border: none; - color: #667eea; + color: var(--color-primary); font-size: 1.1em; cursor: pointer; padding: 0; @@ -1003,17 +1049,17 @@ main { .muscle-tag { padding: 6px 12px; - background: #667eea; + background: var(--color-primary); color: white; - border-radius: .8em; + border-radius: var(--radius); display: flex; align-items: center; gap: 8px; } .muscle-tag--secondary { - background: #1a4a4a; - color: #a8edea; + background: var(--muscle-secondary-bg); + color: var(--muscle-secondary-fg); } .muscle-tag__remove { @@ -1037,25 +1083,25 @@ main { /* ===== Credits Page ===== */ .credits article { padding: 16px; - border: 1px solid #333; + border: 1px solid var(--border-color); border-radius: 12px; - background: #111; + background: var(--bg-card); margin-bottom: 14px; } .credits h2 { margin: 0 0 8px 0; - color: #fff; + color: var(--text-primary); } .credits p { margin: 4px 0; - color: #999; + color: var(--text-muted); line-height: 1.5; } .credits a { - color: #667eea; + color: var(--color-primary); text-decoration: none; } @@ -1066,7 +1112,7 @@ main { .credits ul { margin: 8px 0 0 0; padding-left: 20px; - color: #999; + color: var(--text-muted); line-height: 1.8; } diff --git a/src/components/active_session.rs b/src/components/active_session.rs index 8efbea0..ebeec3e 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 ──────────────────────────────────────────────────────────────── @@ -252,23 +250,8 @@ pub fn SessionView() -> Element { } current_session.end_time = Some(get_current_timestamp()); storage::save_session(current_session.clone()); - // Show congratulatory toast (via global context so it survives unmount) + // Show congratulatory toast (auto-dismiss is handled by CongratulationsToast) congratulations.set(true); - #[cfg(target_arch = "wasm32")] - { - spawn(async move { - gloo_timers::future::TimeoutFuture::new(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; - congratulations.set(false); - }); - } }; let exercise_count = session.read().exercise_logs.len(); @@ -478,12 +461,24 @@ pub fn SessionView() -> Element { if !session.read().exercise_logs.is_empty() { section { h3 { "Completed Exercises" } - for (idx, log) in session.read().exercise_logs.iter().enumerate().rev() { - CompletedExerciseLog { - key: "{idx}", - idx, - log: log.clone(), - session, + { + let no_exercise_active = current_exercise_id.read().is_none(); + rsx! { + for (idx, log) in session.read().exercise_logs.iter().enumerate().rev() { + CompletedExerciseLog { + key: "{idx}", + idx, + log: log.clone(), + session, + show_replay: no_exercise_active, + on_replay: { + let id = log.exercise_id.clone(); + let name = log.exercise_name.clone(); + let cat = log.category; + move |_| start_exercise(id.clone(), name.clone(), cat) + }, + } + } } } } diff --git a/src/components/analytics.rs b/src/components/analytics.rs index ee8c657..efde971 100644 --- a/src/components/analytics.rs +++ b/src/components/analytics.rs @@ -105,7 +105,7 @@ pub fn AnalyticsPage() -> Element { h1 { "📊 Analytics" } p { "Track your progress over time" } } - main { class: "analytics", + main { class: "analytics-panel", section { class: "controls", label { class: "form-label form-label--color", "Select Metric" } select { @@ -229,13 +229,7 @@ fn ChartView( }; let format_date = |timestamp: f64| -> String { - #[cfg(target_arch = "wasm32")] - let current_time = js_sys::Date::now() / 1000.0; - #[cfg(not(target_arch = "wasm32"))] - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as f64; + let current_time = time::OffsetDateTime::now_utc().unix_timestamp() as f64; let days_ago = ((current_time - timestamp) / 86400.0) as i64; match days_ago { diff --git a/src/components/completed_exercise_log.rs b/src/components/completed_exercise_log.rs index d6f1a80..d1851ba 100644 --- a/src/components/completed_exercise_log.rs +++ b/src/components/completed_exercise_log.rs @@ -10,6 +10,12 @@ pub fn CompletedExerciseLog( idx: usize, log: ExerciseLog, session: Signal, + /// Called when the user clicks the replay button to start another set. + #[props(default)] + on_replay: EventHandler<()>, + /// Whether to show the replay button (only in an active session with no exercise in progress). + #[props(default)] + show_replay: bool, ) -> Element { let mut is_editing = use_signal(|| false); let mut edit_weight_input = use_signal(String::new); @@ -68,6 +74,14 @@ pub fn CompletedExerciseLog( class: "completed-log__header", h4 { class: "completed-log__title", "{log.exercise_name}" } div { class: "completed-log__actions", + if show_replay { + 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..97da2ff 100644 --- a/src/components/exercise_list.rs +++ b/src/components/exercise_list.rs @@ -1,6 +1,6 @@ use crate::components::{ActiveTab, BottomNav, ExerciseCard}; use crate::services::{exercise_db, storage}; -use crate::Route; +use crate::{ExerciseSearchSignal, Route}; use dioxus::prelude::*; /// Number of exercises loaded per scroll increment. @@ -14,6 +14,16 @@ pub fn ExerciseListPage() -> Element { let mut search_query = use_signal(String::new); let mut visible_count = use_signal(|| PAGE_SIZE); + // If another page set a search query via the global signal, consume it. + let mut search_signal = use_context::().0; + use_effect(move || { + let q = search_signal.read().clone(); + if let Some(q) = q { + search_query.set(q); + search_signal.set(None); + } + }); + // Collect exercise IDs from the active session (if any) let active_session_ids = use_memo(move || { let mut ids = std::collections::HashSet::new(); @@ -88,28 +98,43 @@ pub fn ExerciseListPage() -> Element { results }); - // Set up scroll-based auto-pagination: load more items as the user scrolls down. - // Uses eval to run JavaScript in the underlying renderer (browser or WebView). - // window.onscroll assignment (rather than addEventListener) avoids accumulating + // Set up scroll-based auto-pagination via a web-sys scroll event listener. + // Using `use_hook` ensures the listener is registered once per component mount. + // `window.onscroll` assignment (rather than addEventListener) avoids accumulating // duplicate listeners across component remounts. - let _scroll_listener = use_resource(move || async move { - let mut rx = dioxus::document::eval( - r#"window.onscroll = function() { - var scrollTop = window.scrollY || document.documentElement.scrollTop; - var clientHeight = window.innerHeight || document.documentElement.clientHeight; - var scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight; - if (scrollTop + clientHeight >= scrollHeight - 300) { - dioxus.send(true); + #[cfg(target_arch = "wasm32")] + use_hook(move || { + use wasm_bindgen::prelude::*; + use wasm_bindgen::JsCast; + + let closure = Closure::::new(move || { + let Some(window) = web_sys::window() else { + return; + }; + let Some(doc) = window.document() else { return }; + let Some(el) = doc.document_element() else { + return; + }; + + let scroll_top = window.scroll_y().unwrap_or(0.0); + let client_height = el.client_height() as f64; + let scroll_height = el.scroll_height() as f64; + + if scroll_top + client_height >= scroll_height - 300.0 { + let cur = *visible_count.peek(); + let total = exercises.peek().len(); + if cur < total { + visible_count.set(cur + PAGE_SIZE); } - };"#, - ); - while rx.recv::().await.is_ok() { - let cur = *visible_count.peek(); - let total = exercises.peek().len(); - if cur < total { - visible_count.set(cur + PAGE_SIZE); } + }); + + if let Some(window) = web_sys::window() { + let js_fn: &js_sys::Function = closure.as_ref().unchecked_ref(); + let _ = js_sys::Reflect::set(&window, &"onscroll".into(), js_fn); } + // Leak the closure so it lives for the page lifetime. + closure.forget(); }); // Visible items, annotated with whether instructions should be shown. diff --git a/src/components/home.rs b/src/components/home.rs index 16de9d1..b905e1d 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::{ExerciseSearchSignal, 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 mut search_signal = use_context::().0; + let navigator = use_navigator(); let duration = session .end_time @@ -139,7 +142,17 @@ 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}" } + span { + class: "session-card__exercise-name session-card__exercise-name--clickable", + onclick: { + let name = name.clone(); + move |_| { + search_signal.set(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..29e5102 100644 --- a/src/components/session_timers.rs +++ b/src/components/session_timers.rs @@ -21,6 +21,14 @@ pub(super) fn send_notification(is_duration_bell: bool) { let send = |t: &str, b: &str| { let opts = NotificationOptions::new(); opts.set_body(b); + opts.set_tag(if is_duration_bell { + "logout-duration" + } else { + "logout-rest" + }); + // Vibrate to ensure the notification is felt on mobile devices + let vibrate = serde_wasm_bindgen::to_value(&[200u32, 100, 200]).unwrap(); + opts.set_vibrate(&vibrate); let _ = Notification::new_with_options(t, &opts); }; diff --git a/src/main.rs b/src/main.rs index dc6e6a7..d147ef1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,17 @@ use components::{ #[derive(Clone, Copy)] pub struct CongratulationsSignal(pub Signal); +/// Global context signal for a general-purpose toast message. +#[derive(Clone, Copy)] +pub struct ToastSignal(pub Signal>); + +/// Auto-dismiss delay for toasts in milliseconds. +const TOAST_DISMISS_MS: u32 = 3_000; + +/// Global context signal for pre-filling the exercise list search query. +#[derive(Clone, Copy)] +pub struct ExerciseSearchSignal(pub Signal>); + #[derive(Clone, Routable, Debug, PartialEq)] #[rustfmt::skip] #[allow(clippy::enum_variant_names)] @@ -42,15 +53,6 @@ fn main() { // Prevent the device screen from sleeping while the app is open services::wake_lock::enable_wake_lock(); - // Request notification permission so rest/duration alerts can be shown - #[cfg(all(target_arch = "wasm32", feature = "web-platform"))] - { - use web_sys::NotificationPermission; - if web_sys::Notification::permission() == NotificationPermission::Default { - let _ = web_sys::Notification::request_permission(); - } - } - launch(App); } @@ -60,18 +62,58 @@ fn App() -> Element { services::storage::provide_app_state(); services::exercise_db::provide_exercises(); use_context_provider(|| CongratulationsSignal(Signal::new(false))); + use_context_provider(|| ToastSignal(Signal::new(None))); + use_context_provider(|| ExerciseSearchSignal(Signal::new(None))); + + // Ensure notification permission is granted on every app start + #[cfg(all(target_arch = "wasm32", feature = "web-platform"))] + { + let mut toast = use_context::().0; + // Run once on mount: `use_hook` ensures the check is only performed on first render. + use_hook(move || { + use web_sys::NotificationPermission; + match web_sys::Notification::permission() { + NotificationPermission::Default => { + let _ = web_sys::Notification::request_permission(); + } + NotificationPermission::Denied => { + toast.set(Some( + "⚠️ Notifications are blocked – timer alerts won't fire".to_string(), + )); + } + _ => {} + } + }); + } rsx! { Stylesheet { href: asset!("/assets/styles.css") } Router:: {} CongratulationsToast {} + Toast {} } } /// Renders the congratulations toast when a session is successfully completed. +/// The auto-dismiss timer lives here (always mounted) so it is never cancelled +/// when the SessionView unmounts. #[component] fn CongratulationsToast() -> Element { let mut show = use_context::().0; + + // Auto-dismiss: when `show` becomes true, schedule a reset after TOAST_DISMISS_MS. + use_effect(move || { + if *show.read() { + spawn(async move { + #[cfg(target_arch = "wasm32")] + gloo_timers::future::TimeoutFuture::new(TOAST_DISMISS_MS).await; + #[cfg(not(target_arch = "wasm32"))] + tokio::time::sleep(std::time::Duration::from_millis(TOAST_DISMISS_MS as u64)).await; + show.set(false); + }); + } + }); + if *show.read() { rsx! { div { @@ -84,3 +126,34 @@ fn CongratulationsToast() -> Element { rsx! {} } } + +/// General-purpose toast component that auto-dismisses after [`TOAST_DISMISS_MS`]. +#[component] +fn Toast() -> Element { + let mut toast = use_context::().0; + + use_effect(move || { + if toast.read().is_some() { + spawn(async move { + #[cfg(target_arch = "wasm32")] + gloo_timers::future::TimeoutFuture::new(TOAST_DISMISS_MS).await; + #[cfg(not(target_arch = "wasm32"))] + tokio::time::sleep(std::time::Duration::from_millis(TOAST_DISMISS_MS as u64)).await; + toast.set(None); + }); + } + }); + + let msg = toast.read().clone(); + if let Some(msg) = msg { + rsx! { + div { + class: "snackbar", + onclick: move |_| toast.set(None), + "{msg}" + } + } + } else { + rsx! {} + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 8477e3e..3493542 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -520,22 +520,10 @@ impl WorkoutSession { } /// Get current timestamp compatible with WASM and native platforms. -/// On WASM uses JavaScript's `Date.now()` via `js_sys`; on native uses -/// `std::time::SystemTime`. +/// Uses the `time` crate which handles both WASM (via `wasm-bindgen` feature) +/// and native seamlessly. pub fn get_current_timestamp() -> u64 { - #[cfg(target_arch = "wasm32")] - { - (js_sys::Date::now() / 1000.0) as u64 - } - - #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } + time::OffsetDateTime::now_utc().unix_timestamp() as u64 } /// Format a duration in seconds as HH:MM:SS or MM:SS diff --git a/src/services/exercise_db.rs b/src/services/exercise_db.rs index dd8c19a..7e7066b 100644 --- a/src/services/exercise_db.rs +++ b/src/services/exercise_db.rs @@ -19,10 +19,6 @@ const EXERCISE_DB_REFRESH_INTERVAL_SECS: u64 = 7 * 24 * 60 * 60; /// (localStorage on WASM, config file on native). const LAST_FETCH_KEY: &str = "exercise_db_last_fetch"; -/// Milliseconds per second – used when converting `Date.now()` to Unix seconds. -#[cfg(target_arch = "wasm32")] -const MILLIS_PER_SECOND: f64 = 1000.0; - /// Returns the URL for the exercises JSON file. /// Available on all platforms; `get_exercise_db_url()` handles per-platform config. fn exercises_json_url() -> String { @@ -53,7 +49,7 @@ pub(crate) fn is_refresh_due() -> bool { let Ok(last_fetch) = ts_str.parse::() else { return true; }; - let now_secs = (js_sys::Date::now() / MILLIS_PER_SECOND) as u64; + let now_secs = time::OffsetDateTime::now_utc().unix_timestamp() as u64; let last_secs = last_fetch as u64; now_secs.saturating_sub(last_secs) >= EXERCISE_DB_REFRESH_INTERVAL_SECS } @@ -65,10 +61,7 @@ pub(crate) fn is_refresh_due() -> bool { use crate::services::storage::native_storage; let last_fetch = native_storage::get_config_value(LAST_FETCH_KEY).and_then(|s| s.parse::().ok()); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + let now = time::OffsetDateTime::now_utc().unix_timestamp() as u64; is_refresh_due_for(now, last_fetch) } @@ -90,7 +83,7 @@ pub(crate) fn record_fetch_timestamp() { let Ok(Some(storage)) = window.local_storage() else { return; }; - let now = (js_sys::Date::now() / MILLIS_PER_SECOND).to_string(); + let now = time::OffsetDateTime::now_utc().unix_timestamp().to_string(); let _ = storage.set_item(LAST_FETCH_KEY, &now); } @@ -98,11 +91,7 @@ pub(crate) fn record_fetch_timestamp() { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn record_fetch_timestamp() { use crate::services::storage::native_storage; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - .to_string(); + let now = time::OffsetDateTime::now_utc().unix_timestamp().to_string(); let _ = native_storage::set_config_value(LAST_FETCH_KEY, &now); }