) => {
- if (!prefs) return
+ if (!prefs) {
+ console.error('[notification-settings] update called before preferences loaded')
+ return
+ }
const next: NotificationPreferences = { ...prefs, ...patch }
updatePreferences.mutate(next)
}
@@ -120,6 +124,8 @@ export function NotificationSettings() {
Failed to save. Please try again.
)}
+ {!isTauri() && 'Notification' in window && }
+
{/* Conditional subsections -- only shown when master toggle is on */}
{prefs.enabled && (
@@ -178,9 +184,9 @@ export function NotificationSettings() {
)}
- {isBrowser && (
+ {isBrowser && prefs.sessionReminders.enabled && (
- Session reminders require the native app
+ Notifications are delivered while this tab is open
)}
@@ -255,6 +261,55 @@ export function NotificationSettings() {
)
}
+// ---------------------------------------------------------------------------
+// BrowserPermissionStatus -- shows browser notification permission state
+// ---------------------------------------------------------------------------
+
+function BrowserPermissionStatus() {
+ const [permission, setPermission] = useState(
+ 'Notification' in window ? Notification.permission : 'default',
+ )
+
+ useEffect(() => {
+ if (!('permissions' in navigator)) return
+
+ let unmounted = false
+ let status: PermissionStatus | null = null
+ navigator.permissions
+ .query({ name: 'notifications' as PermissionName })
+ .then((s) => {
+ if (unmounted) return
+ status = s
+ setPermission(s.state as NotificationPermission)
+ s.onchange = () => setPermission(s.state as NotificationPermission)
+ })
+ .catch((err) => console.warn('[notification-settings] Permission query failed:', err))
+
+ return () => {
+ unmounted = true
+ if (status) status.onchange = null
+ }
+ }, [])
+
+ // granted: no extra UI needed (silent)
+ if (permission === 'granted') return null
+
+ if (permission === 'denied') {
+ return (
+
+ Notifications are blocked. Enable them in your browser settings.
+
+ )
+ }
+
+ // default
+ return (
+
+ Enable notifications in your browser when prompted
+
+ )
+}
+
// ---------------------------------------------------------------------------
// ToggleRow -- label + switch in a flex row, 48px+ touch target
// ---------------------------------------------------------------------------
diff --git a/src/hooks/__tests__/use-active-workout-notification.test.ts b/src/hooks/__tests__/use-active-workout-notification.test.ts
new file mode 100644
index 0000000..8b3c74e
--- /dev/null
+++ b/src/hooks/__tests__/use-active-workout-notification.test.ts
@@ -0,0 +1,267 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { TestWrapper } from '@/test/render-helpers'
+import { buildWorkoutLog, buildExercise, resetFactoryCounters } from '@/test/factories'
+import { createMockAdapter } from '@/test/mocks/data-adapter'
+import { useActiveWorkoutStore } from '@/stores/active-workout-store'
+import { DEFAULT_NOTIFICATION_PREFERENCES } from '@/domain/types/notification'
+import type { DataAdapter } from '@/lib/data-adapter'
+import type { LoggedActivityGroup, LoggedActivity, LoggedSet } from '@/domain/types'
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+let mockAdapter: DataAdapter
+
+vi.mock('@/lib/adapter', () => ({
+ getAdapter: () => mockAdapter,
+}))
+
+vi.mock('@tauri-apps/api/core', () => ({
+ isTauri: () => false,
+ invoke: vi.fn(),
+}))
+
+vi.mock('@tauri-apps/api/event', () => ({
+ listen: vi.fn(),
+}))
+
+const mockGetNotificationPreferences = vi.fn()
+const mockSendRestTimerNotification = vi.fn()
+
+vi.mock('@/lib/notification-service', () => ({
+ getNotificationPreferences: (...args: unknown[]) => mockGetNotificationPreferences(...args),
+ sendRestTimerNotification: (...args: unknown[]) => mockSendRestTimerNotification(...args),
+}))
+
+import { useActiveWorkout } from '../use-active-workout'
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const DEFAULT_PREFS = { ...DEFAULT_NOTIFICATION_PREFERENCES }
+
+/**
+ * Intercept startRestTimer calls by wrapping the store's implementation.
+ * Must be called BEFORE renderHook so the hook picks up the wrapped function.
+ */
+function interceptStartRestTimer() {
+ const calls: unknown[][] = []
+ const original = useActiveWorkoutStore.getState().startRestTimer
+
+ useActiveWorkoutStore.setState({
+ startRestTimer: (...args: Parameters) => {
+ calls.push(args)
+ // Don't call original -- it uses setInterval which complicates cleanup.
+ // We only need to verify the arguments passed.
+ },
+ })
+
+ return { calls, restore: () => useActiveWorkoutStore.setState({ startRestTimer: original }) }
+}
+
+/** Set up a started workout with one exercise so confirmSet has a valid target. */
+async function setupWorkoutWithExercise(result: { current: ReturnType }) {
+ const createdLog = buildWorkoutLog({ id: 'wl-1', userId: 'user-1' })
+ vi.mocked(mockAdapter.createWorkoutLog).mockResolvedValue(createdLog)
+
+ const savedGroup: LoggedActivityGroup = {
+ id: 'lag-1',
+ workoutLogId: 'wl-1',
+ groupType: 'STRAIGHT_SETS',
+ ordinal: 1,
+ }
+ const savedActivity: LoggedActivity = {
+ id: 'la-1',
+ loggedGroupId: 'lag-1',
+ exerciseId: 'ex-squat',
+ ordinal: 1,
+ }
+ vi.mocked(mockAdapter.createLoggedActivityGroup).mockResolvedValue(savedGroup)
+ vi.mocked(mockAdapter.createLoggedActivity).mockResolvedValue(savedActivity)
+
+ await act(async () => {
+ await result.current.startWorkout('user-1')
+ })
+
+ const exercise = buildExercise({ id: 'ex-squat', name: 'Squat' })
+ await act(async () => {
+ await result.current.addExercise(exercise, 'STRAIGHT_SETS')
+ })
+
+ return { createdLog, savedActivity }
+}
+
+/** Build a minimal set data object for confirmSet. */
+function buildSetData(overrides?: Partial>): Omit {
+ return {
+ loggedActivityId: 'la-1',
+ setNumber: 1,
+ setType: 'WORKING' as const,
+ completed: false,
+ actualReps: 5,
+ actualWeight: { value: 225, unit: 'lb' as const },
+ ...overrides,
+ }
+}
+
+/** Mock createLoggedSet to return a saved set. */
+function mockCreateLoggedSet(overrides?: Partial) {
+ const savedSet: LoggedSet = {
+ id: 'ls-1',
+ loggedActivityId: 'la-1',
+ setNumber: 1,
+ setType: 'WORKING',
+ completed: true,
+ actualReps: 5,
+ actualWeight: { value: 225, unit: 'lb' as const },
+ ...overrides,
+ }
+ vi.mocked(mockAdapter.createLoggedSet).mockResolvedValue(savedSet)
+ return savedSet
+}
+
+// ---------------------------------------------------------------------------
+// Setup / Teardown
+// ---------------------------------------------------------------------------
+
+beforeEach(() => {
+ resetFactoryCounters()
+ mockAdapter = createMockAdapter()
+ useActiveWorkoutStore.getState().discardWorkout()
+
+ mockGetNotificationPreferences.mockResolvedValue(DEFAULT_PREFS)
+ mockSendRestTimerNotification.mockResolvedValue(undefined)
+})
+
+afterEach(() => {
+ useActiveWorkoutStore.getState().cleanup()
+ vi.restoreAllMocks()
+})
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('confirmSet notification wiring', () => {
+ it('passes onExpired callback to startRestTimer when restSeconds > 0', async () => {
+ const spy = interceptStartRestTimer()
+
+ const { result } = renderHook(() => useActiveWorkout(), { wrapper: TestWrapper })
+ await setupWorkoutWithExercise(result)
+ mockCreateLoggedSet()
+
+ await act(async () => {
+ await result.current.confirmSet('la-1', buildSetData(), 90, 'Squat')
+ })
+
+ // Wait for the async getNotificationPreferences().then() to fire
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(spy.calls.length).toBeGreaterThan(0)
+ })
+ })
+
+ const [seconds, exerciseName, setNumber, onExpired] = spy.calls[0]
+ expect(seconds).toBe(90)
+ expect(exerciseName).toBe('Squat')
+ expect(setNumber).toBe(1)
+ expect(typeof onExpired).toBe('function')
+
+ // Call the captured onExpired callback
+ await act(async () => {
+ await (onExpired as () => void)()
+ })
+
+ expect(mockSendRestTimerNotification).toHaveBeenCalledWith('Squat', 1, DEFAULT_PREFS)
+
+ spy.restore()
+ })
+
+ it('does not start rest timer when restSeconds is 0', async () => {
+ const spy = interceptStartRestTimer()
+
+ const { result } = renderHook(() => useActiveWorkout(), { wrapper: TestWrapper })
+ await setupWorkoutWithExercise(result)
+ mockCreateLoggedSet()
+
+ await act(async () => {
+ await result.current.confirmSet('la-1', buildSetData(), 0)
+ })
+
+ // Give async code a chance to run
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 50))
+ })
+
+ expect(spy.calls.length).toBe(0)
+
+ spy.restore()
+ })
+
+ it('passes correct exerciseName and setNumber to sendRestTimerNotification', async () => {
+ const spy = interceptStartRestTimer()
+
+ const { result } = renderHook(() => useActiveWorkout(), { wrapper: TestWrapper })
+ await setupWorkoutWithExercise(result)
+ mockCreateLoggedSet({ setNumber: 3, actualReps: 8, actualWeight: { value: 185, unit: 'lb' } })
+
+ await act(async () => {
+ await result.current.confirmSet('la-1', buildSetData({ setNumber: 3 }), 60, 'Bench Press')
+ })
+
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(spy.calls.length).toBeGreaterThan(0)
+ })
+ })
+
+ const onExpired = spy.calls[0][3] as () => void
+ expect(typeof onExpired).toBe('function')
+
+ await act(async () => {
+ await onExpired()
+ })
+
+ expect(mockSendRestTimerNotification).toHaveBeenCalledWith('Bench Press', 3, DEFAULT_PREFS)
+
+ spy.restore()
+ })
+
+ it('starts timer without onExpired callback when notification prefs fail to load', async () => {
+ mockGetNotificationPreferences.mockRejectedValue(new Error('Storage unavailable'))
+
+ const spy = interceptStartRestTimer()
+
+ const { result } = renderHook(() => useActiveWorkout(), { wrapper: TestWrapper })
+ await setupWorkoutWithExercise(result)
+ mockCreateLoggedSet()
+
+ // Suppress expected console.error from the catch block
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ await act(async () => {
+ await result.current.confirmSet('la-1', buildSetData(), 120, 'Deadlift')
+ })
+
+ // Wait for the rejected promise's catch handler to fire
+ await act(async () => {
+ await vi.waitFor(() => {
+ expect(spy.calls.length).toBeGreaterThan(0)
+ })
+ })
+
+ // startRestTimer should still be called, but without the onExpired callback
+ const [seconds, exerciseName, setNumber, onExpired] = spy.calls[0]
+ expect(seconds).toBe(120)
+ expect(exerciseName).toBe('Deadlift')
+ expect(setNumber).toBe(1)
+ expect(onExpired).toBeUndefined()
+
+ consoleErrorSpy.mockRestore()
+ spy.restore()
+ })
+})
diff --git a/src/hooks/__tests__/use-session-reminder-browser.test.ts b/src/hooks/__tests__/use-session-reminder-browser.test.ts
new file mode 100644
index 0000000..c2f4c44
--- /dev/null
+++ b/src/hooks/__tests__/use-session-reminder-browser.test.ts
@@ -0,0 +1,380 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook } from '@testing-library/react'
+
+// ---------------------------------------------------------------------------
+// Mocks -- must be declared before importing the hook
+// ---------------------------------------------------------------------------
+
+const mockIsTauri = vi.fn(() => false)
+vi.mock('@tauri-apps/api/core', () => ({
+ isTauri: () => mockIsTauri(),
+ invoke: vi.fn(),
+}))
+
+const mockGetSupabaseClient = vi.fn()
+vi.mock('@/lib/supabase', () => ({
+ getSupabaseClient: () => mockGetSupabaseClient(),
+}))
+
+const mockGetNotificationPreferences = vi.fn()
+const mockIsInQuietHours = vi.fn((_prefs: unknown) => false)
+const mockSendSessionReminderNotification = vi.fn()
+vi.mock('@/lib/notification-service', () => ({
+ getNotificationPreferences: () => mockGetNotificationPreferences(),
+ isInQuietHours: (prefs: unknown) => mockIsInQuietHours(prefs),
+ sendSessionReminderNotification: (...args: unknown[]) =>
+ mockSendSessionReminderNotification(...args),
+}))
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeEnabledPrefs() {
+ return {
+ enabled: true,
+ restTimer: { enabled: true, soundEnabled: true, vibrationEnabled: true },
+ sessionReminders: { enabled: true, advanceMinutes: 30 },
+ prCelebrations: { enabled: true },
+ quietHours: { enabled: false, startHour: 22, startMinute: 0, endHour: 6, endMinute: 0 },
+ }
+}
+
+/** Build a chainable mock Supabase query builder. */
+function makeQueryBuilder(resolvedData: unknown, resolvedError: unknown = null) {
+ const builder: Record = {}
+ const terminalResult = { data: resolvedData, error: resolvedError }
+
+ // Every chainable method returns the builder itself
+ for (const method of ['select', 'eq', 'gte', 'lt', 'limit']) {
+ builder[method] = vi.fn(() => builder)
+ }
+ // Terminal methods resolve the result
+ builder.maybeSingle = vi.fn(() => Promise.resolve(terminalResult))
+ // When no .maybeSingle() is called (e.g. scheduled_sessions, workout_logs),
+ // the builder itself acts as a thenable so `await supabase.from(...)...` resolves
+ builder.then = (resolve: (v: unknown) => void) => resolve(terminalResult)
+
+ return builder
+}
+
+/**
+ * Create a mock Supabase client where `.from(table)` returns a pre-configured
+ * query builder per table.
+ */
+function makeMockSupabase(config: {
+ activation?: object | null
+ block?: object | null
+ blockWeek?: object | null
+ sessions?: object[] | null
+ todayLogs?: object[] | null
+}) {
+ const builders: Record> = {
+ program_activations: makeQueryBuilder(config.activation ?? null),
+ blocks: makeQueryBuilder(config.block ?? null),
+ block_weeks: makeQueryBuilder(config.blockWeek ?? null),
+ scheduled_sessions: makeQueryBuilder(config.sessions ?? null),
+ workout_logs: makeQueryBuilder(config.todayLogs ?? null),
+ }
+
+ return {
+ from: vi.fn((table: string) => builders[table] ?? makeQueryBuilder(null)),
+ }
+}
+
+function defaultSupabaseConfig() {
+ return {
+ activation: {
+ id: 'act-1',
+ program_id: 'prog-1',
+ current_block_ordinal: 1,
+ current_week_number: 1,
+ },
+ block: { id: 'block-1' },
+ blockWeek: { id: 'bw-1' },
+ sessions: [
+ {
+ id: 'ss-1',
+ session_template_id: 'st-1',
+ session_templates: { name: 'Upper Body' },
+ },
+ ],
+ todayLogs: [] as { id: string }[],
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Module-level _lastRemindedDate persists across tests. We use
+// vi.resetModules() + dynamic import to get a fresh module per test.
+// ---------------------------------------------------------------------------
+
+let useSessionReminderBrowser: () => void
+
+async function freshImport() {
+ vi.resetModules()
+ const mod = await import('../use-session-reminder-browser')
+ useSessionReminderBrowser = mod.useSessionReminderBrowser
+}
+
+// ---------------------------------------------------------------------------
+// Suite
+// ---------------------------------------------------------------------------
+
+describe('useSessionReminderBrowser', () => {
+ beforeEach(async () => {
+ vi.useFakeTimers()
+ // Pin time to 10:00 on a Wednesday (day_of_week = 3)
+ vi.setSystemTime(new Date(2026, 3, 15, 10, 0, 0)) // 2026-04-15 10:00
+
+ mockIsTauri.mockReturnValue(false)
+ mockIsInQuietHours.mockReturnValue(false)
+ mockGetNotificationPreferences.mockResolvedValue(makeEnabledPrefs())
+ mockSendSessionReminderNotification.mockResolvedValue(undefined)
+
+ await freshImport()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ })
+
+ // -------------------------------------------------------------------------
+ // 1. fires notification when all conditions pass
+ // -------------------------------------------------------------------------
+ it('fires notification when all conditions pass', async () => {
+ const supabase = makeMockSupabase(defaultSupabaseConfig())
+ mockGetSupabaseClient.mockReturnValue(supabase)
+
+ renderHook(() => useSessionReminderBrowser())
+
+ // The hook calls checkAndMaybeNotify() immediately (Promise-based).
+ // Flush the microtask queue so all awaits resolve.
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(mockSendSessionReminderNotification).toHaveBeenCalledTimes(1)
+ expect(mockSendSessionReminderNotification).toHaveBeenCalledWith(
+ 'Upper Body',
+ expect.objectContaining({ enabled: true }),
+ )
+ })
+
+ // -------------------------------------------------------------------------
+ // 2. skips when master toggle off
+ // -------------------------------------------------------------------------
+ it('skips when master toggle off', async () => {
+ const prefs = makeEnabledPrefs()
+ prefs.enabled = false
+ mockGetNotificationPreferences.mockResolvedValue(prefs)
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabase(defaultSupabaseConfig()))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ })
+
+ // -------------------------------------------------------------------------
+ // 3. skips when quiet hours active
+ // -------------------------------------------------------------------------
+ it('skips when quiet hours active', async () => {
+ mockIsInQuietHours.mockReturnValue(true)
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabase(defaultSupabaseConfig()))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ })
+
+ // -------------------------------------------------------------------------
+ // 4. skips when outside 06-20 time window
+ // -------------------------------------------------------------------------
+ it('skips when outside 06-20 time window (before 6)', async () => {
+ vi.setSystemTime(new Date(2026, 3, 15, 4, 0, 0)) // 04:00
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabase(defaultSupabaseConfig()))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ })
+
+ it('skips when outside 06-20 time window (after 20)', async () => {
+ vi.setSystemTime(new Date(2026, 3, 15, 21, 0, 0)) // 21:00
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabase(defaultSupabaseConfig()))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ })
+
+ // -------------------------------------------------------------------------
+ // 5. skips when workout already logged today
+ // -------------------------------------------------------------------------
+ it('skips when workout already logged today', async () => {
+ const config = defaultSupabaseConfig()
+ config.todayLogs = [{ id: 'log-1' }]
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabase(config))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ })
+
+ // -------------------------------------------------------------------------
+ // 6. fires at most once per day
+ // -------------------------------------------------------------------------
+ it('fires at most once per day', async () => {
+ const supabase = makeMockSupabase(defaultSupabaseConfig())
+ mockGetSupabaseClient.mockReturnValue(supabase)
+
+ renderHook(() => useSessionReminderBrowser())
+
+ // First immediate call fires
+ await vi.advanceTimersByTimeAsync(0)
+ expect(mockSendSessionReminderNotification).toHaveBeenCalledTimes(1)
+
+ // Advance past one interval (60s) to trigger the second poll
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(mockSendSessionReminderNotification).toHaveBeenCalledTimes(1)
+ })
+
+ // -------------------------------------------------------------------------
+ // 7. no-op in Tauri mode
+ // -------------------------------------------------------------------------
+ it('no-op in Tauri mode', async () => {
+ mockIsTauri.mockReturnValue(true)
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabase(defaultSupabaseConfig()))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(mockGetSupabaseClient).not.toHaveBeenCalled()
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ })
+
+ // -------------------------------------------------------------------------
+ // 8. skips when no active program
+ // -------------------------------------------------------------------------
+ it('skips when no active program', async () => {
+ const config = defaultSupabaseConfig()
+ config.activation = null as unknown as typeof config.activation
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabase(config))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ })
+
+ // -------------------------------------------------------------------------
+ // 9. Supabase query error branches
+ // -------------------------------------------------------------------------
+ describe('Supabase query error branches', () => {
+ /**
+ * Build a mock Supabase client where all tables return default data
+ * except the specified table, which returns an error.
+ */
+ function makeMockSupabaseWithError(
+ errorTable:
+ | 'program_activations'
+ | 'blocks'
+ | 'block_weeks'
+ | 'scheduled_sessions'
+ | 'workout_logs',
+ ) {
+ const config = defaultSupabaseConfig()
+ const builders: Record> = {
+ program_activations: makeQueryBuilder(config.activation),
+ blocks: makeQueryBuilder(config.block),
+ block_weeks: makeQueryBuilder(config.blockWeek),
+ scheduled_sessions: makeQueryBuilder(config.sessions),
+ workout_logs: makeQueryBuilder(config.todayLogs),
+ }
+ builders[errorTable] = makeQueryBuilder(null, { message: `${errorTable} query failed` })
+
+ return {
+ from: vi.fn((table: string) => builders[table] ?? makeQueryBuilder(null)),
+ }
+ }
+
+ it('logs error and skips notification on program_activations error', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabaseWithError('program_activations'))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[session-reminder-browser] Failed to query program_activations:',
+ expect.objectContaining({ message: 'program_activations query failed' }),
+ )
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ errorSpy.mockRestore()
+ })
+
+ it('logs error and skips notification on blocks error', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabaseWithError('blocks'))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[session-reminder-browser] Failed to query blocks:',
+ expect.objectContaining({ message: 'blocks query failed' }),
+ )
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ errorSpy.mockRestore()
+ })
+
+ it('logs error and skips notification on block_weeks error', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabaseWithError('block_weeks'))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[session-reminder-browser] Failed to query block_weeks:',
+ expect.objectContaining({ message: 'block_weeks query failed' }),
+ )
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ errorSpy.mockRestore()
+ })
+
+ it('logs error and skips notification on scheduled_sessions error', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabaseWithError('scheduled_sessions'))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[session-reminder-browser] Failed to query scheduled_sessions:',
+ expect.objectContaining({ message: 'scheduled_sessions query failed' }),
+ )
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ errorSpy.mockRestore()
+ })
+
+ it('logs error and skips notification on workout_logs error', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockGetSupabaseClient.mockReturnValue(makeMockSupabaseWithError('workout_logs'))
+
+ renderHook(() => useSessionReminderBrowser())
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[session-reminder-browser] Failed to query workout_logs:',
+ expect.objectContaining({ message: 'workout_logs query failed' }),
+ )
+ expect(mockSendSessionReminderNotification).not.toHaveBeenCalled()
+ errorSpy.mockRestore()
+ })
+ })
+})
diff --git a/src/hooks/use-active-workout.ts b/src/hooks/use-active-workout.ts
index d9ca959..1f2d94e 100644
--- a/src/hooks/use-active-workout.ts
+++ b/src/hooks/use-active-workout.ts
@@ -18,6 +18,7 @@ import {
useDeleteLoggedActivity,
} from '@/hooks/use-workout-logs'
import { getAdapter } from '@/lib/adapter'
+import { getNotificationPreferences, sendRestTimerNotification } from '@/lib/notification-service'
import { DEFAULT_REST_SECONDS } from '@/lib/workout-utils'
import { computeNextProgramPosition } from '@/lib/program-advancement'
import { resolveSessionTemplate } from '@/lib/prescription-resolver'
@@ -368,6 +369,7 @@ export function useActiveWorkout() {
loggedActivityId: string,
setData: Omit,
restSeconds: number = DEFAULT_REST_SECONDS,
+ exerciseName?: string,
) => {
try {
if (!workoutLog) {
@@ -402,7 +404,23 @@ export function useActiveWorkout() {
// Skip the global rest timer for zero/negative values. CircuitPanel
// and similar self-managed timer UIs pass 0 to suppress the duplicate.
if (restSeconds > 0) {
- storeStartRestTimer(restSeconds)
+ const setNum = setData.setNumber
+
+ // Read notification prefs (async), then start the rest timer with
+ // an onExpired callback that fires a browser notification.
+ getNotificationPreferences()
+ .then((prefs) => {
+ storeStartRestTimer(restSeconds, exerciseName, setNum, () => {
+ sendRestTimerNotification(exerciseName, setNum, prefs).catch((err) => {
+ console.error('[workout] Rest timer notification failed:', err)
+ })
+ })
+ })
+ .catch((err) => {
+ // If prefs fail to load, still start the timer without notification
+ console.error('[workout] Failed to load notification preferences:', err)
+ storeStartRestTimer(restSeconds, exerciseName, setNum)
+ })
}
return savedSet
diff --git a/src/hooks/use-session-reminder-browser.ts b/src/hooks/use-session-reminder-browser.ts
new file mode 100644
index 0000000..9726a67
--- /dev/null
+++ b/src/hooks/use-session-reminder-browser.ts
@@ -0,0 +1,149 @@
+import { useEffect } from 'react'
+import { isTauri } from '@tauri-apps/api/core'
+import { getSupabaseClient } from '@/lib/supabase'
+import {
+ getNotificationPreferences,
+ isInQuietHours,
+ sendSessionReminderNotification,
+} from '@/lib/notification-service'
+
+// Module-level: survives hook remounts, resets on full page reload
+let _lastRemindedDate: string | null = null
+
+export function useSessionReminderBrowser(): void {
+ useEffect(() => {
+ if (isTauri()) return
+
+ async function checkAndMaybeNotify(): Promise {
+ try {
+ const prefs = await getNotificationPreferences()
+
+ // Guard: master toggle and session reminders toggle
+ if (!prefs.enabled || !prefs.sessionReminders.enabled) return
+
+ // Guard: quiet hours
+ if (isInQuietHours(prefs)) return
+
+ // Guard: only fire between 06:00 and 20:59 local time
+ const currentHour = new Date().getHours()
+ if (currentHour < 6 || currentHour > 20) return
+
+ // Guard: already reminded today (local calendar date, not UTC)
+ const now = new Date()
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
+ if (_lastRemindedDate === today) return
+
+ const supabase = getSupabaseClient()
+ if (!supabase) {
+ console.error('[session-reminder-browser] Supabase client not initialized')
+ return
+ }
+
+ // 1. Get active program activation
+ const { data: activation, error: activationErr } = await supabase
+ .from('program_activations')
+ .select('id, program_id, current_block_ordinal, current_week_number')
+ .maybeSingle()
+
+ if (activationErr) {
+ console.error(
+ '[session-reminder-browser] Failed to query program_activations:',
+ activationErr,
+ )
+ return
+ }
+ if (!activation) return // No active program
+
+ // 2. Find today's scheduled session for the current block+week position
+ const { data: block, error: blockErr } = await supabase
+ .from('blocks')
+ .select('id')
+ .eq('program_id', activation.program_id)
+ .eq('ordinal', activation.current_block_ordinal)
+ .maybeSingle()
+
+ if (blockErr) {
+ console.error('[session-reminder-browser] Failed to query blocks:', blockErr)
+ return
+ }
+ if (!block) return
+
+ // Get the block_week for current_week_number
+ const { data: blockWeek, error: weekErr } = await supabase
+ .from('block_weeks')
+ .select('id')
+ .eq('block_id', block.id)
+ .eq('week_number', activation.current_week_number)
+ .maybeSingle()
+
+ if (weekErr) {
+ console.error('[session-reminder-browser] Failed to query block_weeks:', weekErr)
+ return
+ }
+ if (!blockWeek) return
+
+ // Get scheduled sessions for today's day_of_week
+ const todayDow = new Date().getDay() // 0=Sunday, matches schema
+ const { data: sessions, error: sessErr } = await supabase
+ .from('scheduled_sessions')
+ .select('id, session_template_id, session_templates(name)')
+ .eq('block_week_id', blockWeek.id)
+ .eq('day_of_week', todayDow)
+
+ if (sessErr) {
+ console.error('[session-reminder-browser] Failed to query scheduled_sessions:', sessErr)
+ return
+ }
+ if (!sessions || sessions.length === 0) return
+
+ // 3. Check if a workout has been logged today
+ const todayStart = new Date()
+ todayStart.setHours(0, 0, 0, 0)
+ const tomorrowStart = new Date(todayStart)
+ tomorrowStart.setDate(tomorrowStart.getDate() + 1)
+
+ const { data: todayLogs, error: logErr } = await supabase
+ .from('workout_logs')
+ .select('id')
+ .gte('started_at', todayStart.toISOString())
+ .lt('started_at', tomorrowStart.toISOString())
+ .limit(1)
+
+ if (logErr) {
+ console.error('[session-reminder-browser] Failed to query workout_logs:', logErr)
+ return
+ }
+
+ if (todayLogs && todayLogs.length > 0) {
+ // Workout already logged -- suppress further checks today
+ _lastRemindedDate = today
+ return
+ }
+
+ // 4. Fire notification for the first scheduled session
+ const session = sessions[0]
+ const rawTemplate = session.session_templates as unknown as { name: string } | null
+ if (!rawTemplate?.name) {
+ console.warn(
+ '[session-reminder-browser] session_templates join returned no name for session:',
+ session.id,
+ )
+ }
+ const sessionName = rawTemplate?.name ?? 'Workout'
+
+ // Set the guard before firing so a notification failure does not cause
+ // a Supabase query spam loop on the next poll interval.
+ _lastRemindedDate = today
+ await sendSessionReminderNotification(sessionName, prefs)
+ } catch (err) {
+ console.error('[session-reminder-browser] Unexpected error during poll:', err)
+ }
+ }
+
+ // Run immediately on mount, then poll every 60s
+ checkAndMaybeNotify()
+ const interval = setInterval(checkAndMaybeNotify, 60_000)
+
+ return () => clearInterval(interval)
+ }, [])
+}
diff --git a/src/lib/__tests__/notification-service.test.ts b/src/lib/__tests__/notification-service.test.ts
new file mode 100644
index 0000000..9e7497b
--- /dev/null
+++ b/src/lib/__tests__/notification-service.test.ts
@@ -0,0 +1,230 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+import type { NotificationPreferences } from '@/domain/types/notification'
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+vi.mock('@tauri-apps/api/core', () => ({
+ isTauri: () => false,
+ invoke: vi.fn(),
+}))
+
+// Mock window.Notification before importing the module under test
+const MockNotification = vi.fn() as ReturnType & {
+ permission: NotificationPermission
+ requestPermission: ReturnType
+}
+const permissionGetter = vi.fn(() => 'granted')
+Object.defineProperty(MockNotification, 'permission', {
+ get: permissionGetter,
+ configurable: true,
+})
+MockNotification.requestPermission = vi.fn().mockResolvedValue('granted')
+
+Object.defineProperty(window, 'Notification', {
+ value: MockNotification,
+ writable: true,
+ configurable: true,
+})
+
+// Import after mocks are established
+import { sendRestTimerNotification, sendSessionReminderNotification } from '../notification-service'
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makePrefs(overrides: Partial = {}): NotificationPreferences {
+ return {
+ enabled: true,
+ restTimer: { enabled: true, soundEnabled: true, vibrationEnabled: true },
+ sessionReminders: { enabled: true, advanceMinutes: 30 },
+ prCelebrations: { enabled: true },
+ quietHours: { enabled: false, startHour: 22, startMinute: 0, endHour: 6, endMinute: 0 },
+ ...overrides,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Setup / Teardown
+// ---------------------------------------------------------------------------
+
+let savedNotification: typeof window.Notification | undefined
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ permissionGetter.mockReturnValue('granted')
+ MockNotification.requestPermission = vi.fn().mockResolvedValue('granted')
+ savedNotification = window.Notification
+})
+
+afterEach(() => {
+ // Restore Notification if a test removed it
+ if (!('Notification' in window) && savedNotification) {
+ Object.defineProperty(window, 'Notification', {
+ value: savedNotification,
+ writable: true,
+ configurable: true,
+ })
+ }
+})
+
+// ---------------------------------------------------------------------------
+// sendRestTimerNotification
+// ---------------------------------------------------------------------------
+
+describe('sendRestTimerNotification', () => {
+ it('fires notification when permission granted and toggles on', async () => {
+ const prefs = makePrefs()
+
+ await sendRestTimerNotification('Bench Press', 3, prefs)
+
+ expect(MockNotification).toHaveBeenCalledOnce()
+ const [title, options] = MockNotification.mock.calls[0]
+ expect(title).toBe('REST COMPLETE')
+ expect(options.body).toContain('Bench Press')
+ expect(options.body).toContain('Set 3')
+ })
+
+ it('no-op when master toggle off', async () => {
+ const prefs = makePrefs({ enabled: false })
+
+ await sendRestTimerNotification('Bench Press', 3, prefs)
+
+ expect(MockNotification).not.toHaveBeenCalled()
+ })
+
+ it('no-op when rest timer toggle off', async () => {
+ const prefs = makePrefs({
+ restTimer: { enabled: false, soundEnabled: true, vibrationEnabled: true },
+ })
+
+ await sendRestTimerNotification('Bench Press', 3, prefs)
+
+ expect(MockNotification).not.toHaveBeenCalled()
+ })
+
+ it('no-op when permission denied', async () => {
+ permissionGetter.mockReturnValue('denied')
+ const prefs = makePrefs()
+
+ await sendRestTimerNotification('Bench Press', 3, prefs)
+
+ expect(MockNotification).not.toHaveBeenCalled()
+ expect(MockNotification.requestPermission).not.toHaveBeenCalled()
+ })
+
+ it('calls requestPermission when permission is default', async () => {
+ permissionGetter.mockReturnValue('default')
+ MockNotification.requestPermission = vi.fn().mockResolvedValue('granted')
+ const prefs = makePrefs()
+
+ await sendRestTimerNotification('Bench Press', 3, prefs)
+
+ expect(MockNotification.requestPermission).toHaveBeenCalledOnce()
+ expect(MockNotification).toHaveBeenCalledOnce()
+ })
+
+ it('does not fire when requestPermission returns denied', async () => {
+ permissionGetter.mockReturnValue('default')
+ MockNotification.requestPermission = vi.fn().mockResolvedValue('denied')
+ const prefs = makePrefs()
+
+ await sendRestTimerNotification('Bench Press', 3, prefs)
+
+ expect(MockNotification.requestPermission).toHaveBeenCalledOnce()
+ expect(MockNotification).not.toHaveBeenCalled()
+ })
+
+ it('rest timer notification is NOT blocked by quiet hours', async () => {
+ const prefs = makePrefs({
+ quietHours: { enabled: true, startHour: 0, startMinute: 0, endHour: 23, endMinute: 59 },
+ })
+
+ await sendRestTimerNotification('Squat', 1, prefs)
+
+ expect(MockNotification).toHaveBeenCalledOnce()
+ })
+
+ it('body shows fallback when exerciseName is undefined', async () => {
+ const prefs = makePrefs()
+
+ await sendRestTimerNotification(undefined, undefined, prefs)
+
+ expect(MockNotification).toHaveBeenCalledOnce()
+ const [, options] = MockNotification.mock.calls[0]
+ expect(options.body).toBe('Time to start your next set')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// sendSessionReminderNotification
+// ---------------------------------------------------------------------------
+
+describe('sendSessionReminderNotification', () => {
+ it('fires notification when permission granted and toggles on', async () => {
+ const prefs = makePrefs()
+
+ await sendSessionReminderNotification('Upper Body', prefs)
+
+ expect(MockNotification).toHaveBeenCalledOnce()
+ const [title, options] = MockNotification.mock.calls[0]
+ expect(title).toBe('SESSION REMINDER')
+ expect(options.body).toContain('Upper Body')
+ })
+
+ it('no-op when master toggle off', async () => {
+ const prefs = makePrefs({ enabled: false })
+
+ await sendSessionReminderNotification('Upper Body', prefs)
+
+ expect(MockNotification).not.toHaveBeenCalled()
+ })
+
+ it('no-op when session reminder toggle off', async () => {
+ const prefs = makePrefs({
+ sessionReminders: { enabled: false, advanceMinutes: 30 },
+ })
+
+ await sendSessionReminderNotification('Upper Body', prefs)
+
+ expect(MockNotification).not.toHaveBeenCalled()
+ })
+
+ it('session reminder IS blocked by quiet hours', async () => {
+ const prefs = makePrefs({
+ quietHours: { enabled: true, startHour: 0, startMinute: 0, endHour: 23, endMinute: 59 },
+ })
+
+ await sendSessionReminderNotification('Upper Body', prefs)
+
+ expect(MockNotification).not.toHaveBeenCalled()
+ })
+
+ it('calls requestPermission when permission is default', async () => {
+ permissionGetter.mockReturnValue('default')
+ MockNotification.requestPermission = vi.fn().mockResolvedValue('granted')
+ const prefs = makePrefs()
+
+ await sendSessionReminderNotification('Upper Body', prefs)
+
+ expect(MockNotification.requestPermission).toHaveBeenCalledOnce()
+ expect(MockNotification).toHaveBeenCalledOnce()
+ })
+
+ it('no-op when Notification API unavailable', async () => {
+ // Remove Notification from window
+ // @ts-expect-error -- intentionally removing for test
+ delete window.Notification
+
+ const prefs = makePrefs()
+
+ await sendSessionReminderNotification('Upper Body', prefs)
+
+ // No error thrown, no notification
+ expect(MockNotification).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/lib/notification-service.ts b/src/lib/notification-service.ts
index 0b554c2..e5e3705 100644
--- a/src/lib/notification-service.ts
+++ b/src/lib/notification-service.ts
@@ -30,8 +30,8 @@ export async function getNotificationPreferences(): Promise {
- const validated = notificationPreferencesSchema.parse(prefs)
- const json = JSON.stringify(validated)
+ const result = notificationPreferencesSchema.safeParse(prefs)
+ if (!result.success) {
+ console.error(
+ '[notification-service] setNotificationPreferences: invalid prefs:',
+ result.error.issues,
+ )
+ throw new Error('Invalid notification preferences')
+ }
+ const json = JSON.stringify(result.data)
if (isTauri()) {
await invoke('set_app_config', { key: TAURI_CONFIG_KEY, value: json })
@@ -131,6 +138,76 @@ export async function sendPrNotification(
// Use the Web Notification API if available and permitted
if ('Notification' in window && Notification.permission === 'granted') {
+ try {
+ new Notification(title, { body })
+ } catch (err) {
+ console.error('[notification-service] Failed to create PR notification:', err)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// sendRestTimerNotification -- fires a platform notification when rest ends
+//
+// Uses contextual permission request (D-4): if permission is 'default',
+// prompts the user at this natural interaction point rather than up front.
+// Rest timer notifications are exempt from quiet hours.
+// ---------------------------------------------------------------------------
+
+export async function sendRestTimerNotification(
+ exerciseName: string | undefined,
+ setNumber: number | undefined,
+ prefs: NotificationPreferences,
+): Promise {
+ if (!shouldSendNotification('restTimer', prefs)) return
+ if (!('Notification' in window)) return
+ if (Notification.permission === 'denied') return
+
+ // Contextual permission request when default (D-4 from Tech.md)
+ if (Notification.permission === 'default') {
+ const result = await Notification.requestPermission()
+ if (result !== 'granted') return
+ }
+
+ const title = 'REST COMPLETE'
+ const parts: string[] = []
+ if (exerciseName) parts.push(exerciseName)
+ if (exerciseName && setNumber != null) parts.push(`Set ${setNumber}`)
+ const body = parts.length > 0 ? parts.join(' \u2014 ') : 'Time to start your next set'
+
+ try {
+ new Notification(title, { body })
+ } catch (err) {
+ console.error('[notification-service] Failed to create rest timer notification:', err)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// sendSessionReminderNotification -- fires a reminder for a scheduled session
+//
+// Uses contextual permission request (D-4). Session reminders respect quiet
+// hours (unlike rest timer which is exempt).
+// ---------------------------------------------------------------------------
+
+export async function sendSessionReminderNotification(
+ sessionName: string,
+ prefs: NotificationPreferences,
+): Promise {
+ if (!shouldSendNotification('sessionReminders', prefs)) return
+ if (!('Notification' in window)) return
+ if (Notification.permission === 'denied') return
+
+ if (Notification.permission === 'default') {
+ const result = await Notification.requestPermission()
+ if (result !== 'granted') return
+ }
+
+ const title = 'SESSION REMINDER'
+ const body = `${sessionName} is scheduled for today`
+
+ try {
new Notification(title, { body })
+ } catch (err) {
+ console.error('[notification-service] Failed to create session reminder notification:', err)
}
}
diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx
index eddfeb6..bf26216 100644
--- a/src/routes/_authenticated.tsx
+++ b/src/routes/_authenticated.tsx
@@ -7,6 +7,7 @@ import {
} from '@tanstack/react-router'
import { useEffect } from 'react'
import { isTauri, invoke } from '@tauri-apps/api/core'
+import { useSessionReminderBrowser } from '@/hooks/use-session-reminder-browser'
import { useAuth } from '@/lib/auth'
import { cn } from '@/lib/utils'
import { SyncIndicator } from '@/components/layout/sync-indicator'
@@ -37,6 +38,9 @@ function AuthenticatedLayout() {
}
}, [loading, user, isGuest, navigate])
+ // Start the browser session reminder scheduler (no-ops in Tauri mode).
+ useSessionReminderBrowser()
+
// Start the session reminder background scheduler in Tauri mode.
// The Rust scheduler polls every 60s and respects notification preferences.
useEffect(() => {
diff --git a/src/stores/__tests__/active-workout-store-rest-expired.test.ts b/src/stores/__tests__/active-workout-store-rest-expired.test.ts
new file mode 100644
index 0000000..9a8583b
--- /dev/null
+++ b/src/stores/__tests__/active-workout-store-rest-expired.test.ts
@@ -0,0 +1,213 @@
+import { useActiveWorkoutStore } from '@/stores/active-workout-store'
+import type { WorkoutLog, LoggedActivityGroup, LoggedActivity, GroupType } from '@/domain/types'
+import type { LoggedActivityGroupWithActivities } from '@/stores/active-workout-store'
+
+// ---------------------------------------------------------------------------
+// Fixtures -- minimal objects satisfying required fields
+// ---------------------------------------------------------------------------
+
+const NOW = '2026-03-27T10:00:00Z'
+
+function makeWorkoutLog(overrides?: Partial): WorkoutLog {
+ return {
+ id: 'wl-1',
+ createdAt: NOW,
+ updatedAt: NOW,
+ userId: 'user-1',
+ startedAt: NOW,
+ ...overrides,
+ } as WorkoutLog
+}
+
+function makeLoggedActivityGroup(overrides?: Partial): LoggedActivityGroup {
+ return {
+ id: 'lag-1',
+ workoutLogId: 'wl-1',
+ groupType: 'STRAIGHT_SETS' as GroupType,
+ ordinal: 1,
+ ...overrides,
+ } as LoggedActivityGroup
+}
+
+function makeLoggedActivity(overrides?: Partial): LoggedActivity {
+ return {
+ id: 'la-1',
+ loggedGroupId: 'lag-1',
+ exerciseId: 'ex-1',
+ ordinal: 1,
+ ...overrides,
+ } as LoggedActivity
+}
+
+function makeGroupWithActivities(
+ groupOverrides?: Partial,
+ activityOverrides?: Partial,
+): LoggedActivityGroupWithActivities {
+ return {
+ ...makeLoggedActivityGroup(groupOverrides),
+ activities: [
+ {
+ ...makeLoggedActivity(activityOverrides),
+ sets: [],
+ },
+ ],
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function getState() {
+ return useActiveWorkoutStore.getState()
+}
+
+// ---------------------------------------------------------------------------
+// Setup / teardown
+// ---------------------------------------------------------------------------
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ // Prevent interval leakage between tests
+ getState().cleanup()
+ // Reset state fully
+ useActiveWorkoutStore.setState({
+ workoutLog: null,
+ loggedGroups: [],
+ elapsedSeconds: 0,
+ restTimer: null,
+ undoAction: null,
+ })
+ vi.useRealTimers()
+})
+
+// ===========================================================================
+// _onRestExpired callback mechanism
+// ===========================================================================
+
+describe('_onRestExpired callback', () => {
+ it('onExpired callback fires exactly once when browser timer counts to zero', () => {
+ const onExpired = vi.fn()
+
+ getState().startRestTimer(3, undefined, undefined, onExpired)
+
+ // Tick 1: remaining 3 -> 2
+ getState().tickRest()
+ expect(onExpired).not.toHaveBeenCalled()
+ expect(getState().restTimer).not.toBeNull()
+ expect(getState().restTimer!.remaining).toBe(2)
+
+ // Tick 2: remaining 2 -> 1
+ getState().tickRest()
+ expect(onExpired).not.toHaveBeenCalled()
+ expect(getState().restTimer).not.toBeNull()
+ expect(getState().restTimer!.remaining).toBe(1)
+
+ // Tick 3: remaining 1 -> 0, callback fires and timer clears
+ getState().tickRest()
+ expect(onExpired).toHaveBeenCalledTimes(1)
+ expect(getState().restTimer).toBeNull()
+
+ // Subsequent ticks should not fire the callback again
+ getState().tickRest()
+ expect(onExpired).toHaveBeenCalledTimes(1)
+ })
+
+ it('onExpired callback is NOT called on skipRest()', () => {
+ const onExpired = vi.fn()
+
+ getState().startRestTimer(60, undefined, undefined, onExpired)
+ expect(getState().restTimer).not.toBeNull()
+
+ getState().skipRest()
+
+ expect(onExpired).not.toHaveBeenCalled()
+ expect(getState().restTimer).toBeNull()
+ })
+
+ it('onExpired callback is cleared when a new timer starts', () => {
+ const callbackA = vi.fn()
+ const callbackB = vi.fn()
+
+ // Start timer with callback A
+ getState().startRestTimer(5, undefined, undefined, callbackA)
+
+ // Start a new timer with callback B before A expires
+ getState().startRestTimer(2, undefined, undefined, callbackB)
+
+ // Tick to zero (2 ticks for the second timer)
+ getState().tickRest()
+ getState().tickRest()
+
+ // Only callback B should have been called
+ expect(callbackA).not.toHaveBeenCalled()
+ expect(callbackB).toHaveBeenCalledTimes(1)
+ expect(getState().restTimer).toBeNull()
+ })
+
+ it('cleanup() clears onExpired without calling it', () => {
+ const onExpired = vi.fn()
+
+ getState().startRestTimer(30, undefined, undefined, onExpired)
+ expect(getState().restTimer).not.toBeNull()
+
+ getState().cleanup()
+
+ expect(onExpired).not.toHaveBeenCalled()
+
+ // Ticking after cleanup should not fire the callback (restTimer was not
+ // cleared by cleanup itself -- it only clears intervals and the callback).
+ // However, the module-level _onRestExpired is now null, so even if we
+ // manually tick, the callback reference is gone.
+ })
+
+ it('onExpired is null when no callback provided -- ticking to zero does not throw', () => {
+ // Start rest timer without onExpired parameter
+ getState().startRestTimer(1)
+
+ // Tick to zero -- the ?. operator should handle null gracefully
+ expect(() => getState().tickRest()).not.toThrow()
+ expect(getState().restTimer).toBeNull()
+ })
+
+ it('finishWorkout clears onExpired without calling it', () => {
+ const onExpired = vi.fn()
+
+ // Start a workout so finishWorkout has something to clear
+ getState().startWorkout('user-1', makeWorkoutLog())
+ useActiveWorkoutStore.setState({
+ loggedGroups: [makeGroupWithActivities()],
+ })
+
+ getState().startRestTimer(60, 'Bench Press', 3, onExpired)
+ expect(getState().restTimer).not.toBeNull()
+
+ getState().finishWorkout()
+
+ expect(onExpired).not.toHaveBeenCalled()
+ expect(getState().restTimer).toBeNull()
+ expect(getState().workoutLog).toBeNull()
+ })
+
+ it('discardWorkout clears onExpired without calling it', () => {
+ const onExpired = vi.fn()
+
+ // Start a workout so discardWorkout has something to clear
+ getState().startWorkout('user-1', makeWorkoutLog())
+ useActiveWorkoutStore.setState({
+ loggedGroups: [makeGroupWithActivities()],
+ })
+
+ getState().startRestTimer(60, 'Squat', 1, onExpired)
+ expect(getState().restTimer).not.toBeNull()
+
+ getState().discardWorkout()
+
+ expect(onExpired).not.toHaveBeenCalled()
+ expect(getState().restTimer).toBeNull()
+ expect(getState().workoutLog).toBeNull()
+ })
+})
diff --git a/src/stores/active-workout-store.ts b/src/stores/active-workout-store.ts
index 7d10187..e00a1b3 100644
--- a/src/stores/active-workout-store.ts
+++ b/src/stores/active-workout-store.ts
@@ -80,6 +80,7 @@ interface ActiveWorkoutState {
// src/routes/_authenticated/log.$workoutId.tsx) per Tech.md D-1. The store
// only holds the current elapsedSeconds value via setElapsedSeconds.
let _restInterval: ReturnType | null = null
+let _onRestExpired: (() => void) | null = null
// Tauri event unlisten handles for the Rust rest timer path.
// In Tauri mode, the timer runs in Rust and ticks arrive via events;
@@ -158,7 +159,12 @@ interface ActiveWorkoutActions {
clearUndo(): void
// Rest timer
- startRestTimer(seconds: number, exerciseName?: string, setNumber?: number): void
+ startRestTimer(
+ seconds: number,
+ exerciseName?: string,
+ setNumber?: number,
+ onExpired?: () => void,
+ ): void
skipRest(): void
adjustRest(delta: number): void
@@ -262,6 +268,7 @@ export const useActiveWorkoutStore = create void,
+ ) {
// Clear any existing rest interval / Tauri listeners
if (_restInterval) clearInterval(_restInterval)
_cleanupTauriRestListeners()
@@ -496,6 +509,8 @@ export const useActiveWorkoutStore = create('timer_expired', () => {
+ _onRestExpired?.()
+ _onRestExpired = null
_cleanupTauriRestListeners()
set({ restTimer: null })
_publishCurrentState()
@@ -553,6 +570,7 @@ export const useActiveWorkoutStore = create