Timer Signal on Mobile with Notification

This commit is contained in:
AG
2025-12-19 13:00:47 +02:00
parent 1d8bcdd626
commit 4e8feba5fe
4 changed files with 318 additions and 14 deletions

81
src/hooks/timer.worker.ts Normal file
View File

@@ -0,0 +1,81 @@
/* eslint-disable no-restricted-globals */
// Web Worker to handle the timer interval in a background thread.
// This prevents the timer from being throttled when the tab is inactive or screen is off.
let intervalId: ReturnType<typeof setInterval> | null = null;
let targetEndTime: number | null = null;
self.onmessage = (e: MessageEvent) => {
const { type, payload } = e.data;
switch (type) {
case 'START':
if (payload?.endTime) {
targetEndTime = payload.endTime;
// Clear any existing interval
if (intervalId) clearInterval(intervalId);
// Start a fast tick loop
// We tick faster than 1s to ensure we don't miss the :00 mark by much
intervalId = setInterval(() => {
if (!targetEndTime) return;
const now = Date.now();
const timeLeft = Math.max(0, Math.ceil((targetEndTime - now) / 1000));
// Send tick update
self.postMessage({ type: 'TICK', timeLeft });
if (timeLeft <= 0) {
self.postMessage({ type: 'FINISHED' });
// Fire notification directly from worker to bypass frozen main thread
if ('Notification' in self && (self as any).Notification.permission === 'granted') {
try {
// Try ServiceWorker registration first (more reliable on mobile)
// Cast to any because TS dedicated worker scope doesn't know about registration
const swReg = (self as any).registration;
if ('serviceWorker' in self.navigator && swReg && swReg.showNotification) {
swReg.showNotification("Time's Up!", {
body: "Rest period finished",
icon: '/assets/favicon.svg',
vibrate: [200, 100, 200],
tag: 'rest-timer',
renotify: true
});
} else {
// Fallback to standard Notification API
// Cast options to any to allow 'renotify' which might be missing in strict lib
new Notification("Time's Up!", {
body: "Rest period finished",
icon: '/assets/favicon.svg',
tag: 'rest-timer',
['renotify' as any]: true,
} as NotificationOptions);
}
} catch (e) {
console.error('Worker notification failed', e);
}
}
if (intervalId) clearInterval(intervalId);
intervalId = null;
targetEndTime = null;
}
}, 200); // 200ms check for responsiveness
}
break;
case 'PAUSE':
case 'STOP':
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
targetEndTime = null;
break;
}
};
export { };

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { playTimeUpSignal } from '../utils/audio';
import { requestNotificationPermission, sendNotification, vibrateDevice } from '../utils/notifications';
export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED';
@@ -63,35 +64,104 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
const [status, setStatus] = useState<TimerStatus>(initialStatus);
const [duration, setDuration] = useState(initialDuration);
// Worker reference
const workerRef = useRef<Worker | null>(null);
// Initialize Worker
useEffect(() => {
// Create worker instance
workerRef.current = new Worker(new URL('./timer.worker.ts', import.meta.url), { type: 'module' });
workerRef.current.onmessage = (e) => {
const { type, timeLeft: workerTimeLeft } = e.data;
if (type === 'TICK') {
if (document.hidden) {
setTimeLeft(workerTimeLeft);
}
} else if (type === 'FINISHED') {
// Worker says done.
setStatus((prev) => {
if (prev === 'FINISHED') return prev;
playTimeUpSignal();
sendNotification("Time's Up!", "Rest period finished");
vibrateDevice();
if (onFinish) onFinish();
// Cleanup RAF if it was running
if (rafRef.current) cancelAnimationFrame(rafRef.current);
endTimeRef.current = null;
return 'FINISHED';
});
setTimeLeft(0);
}
};
return () => {
workerRef.current?.terminate();
};
}, [onFinish, duration]);
// Recover worker if we restored a RUNNING state
useEffect(() => {
if (initialStatus === 'RUNNING' && savedState?.endTime) {
if (workerRef.current) {
workerRef.current.postMessage({
type: 'START',
payload: { endTime: savedState.endTime }
});
}
}
}, []);
const endTimeRef = useRef<number | null>(savedState?.endTime || null);
const rafRef = useRef<number | null>(null);
const prevDefaultTimeRef = useRef(defaultTime);
// Tick function - defined before effects
// Tick function - defined before effects (RAF version)
const tick = useCallback(() => {
if (!endTimeRef.current) return;
const now = Date.now();
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000));
// Only update state if it changed (to avoid extra renders, though React handles this)
setTimeLeft(remaining);
if (remaining <= 0) {
// Finished
setStatus('FINISHED');
playTimeUpSignal();
if (onFinish) onFinish();
sendNotification("Time's Up!", "Rest period finished");
vibrateDevice();
if (onFinish) onFinish(); // Ensure this is only called once per finish
endTimeRef.current = null;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
endTimeRef.current = null; // Clear end time
// Auto-reset visuals after 3 seconds of "FINISHED" state?
setTimeout(() => {
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
setTimeLeft(prev => prev === 0 ? duration : prev);
}, 3000);
} else {
rafRef.current = requestAnimationFrame(tick);
}
}, [duration, onFinish]);
// Handle Auto-Reset when status becomes FINISHED (covers both active finish and restore-from-finished)
useEffect(() => {
if (status === 'FINISHED') {
const timer = setTimeout(() => {
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
setTimeLeft(prev => prev === 0 ? duration : prev);
}, 3000);
return () => clearTimeout(timer);
}
}, [status, duration]);
// Save to localStorage whenever relevant state changes
useEffect(() => {
const stateToSave = {
@@ -143,17 +213,32 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
// If starting from IDLE or PAUSED
const targetSeconds = status === 'PAUSED' ? timeLeft : duration;
endTimeRef.current = Date.now() + targetSeconds * 1000;
const endTime = Date.now() + targetSeconds * 1000;
endTimeRef.current = endTime;
setStatus('RUNNING');
// Effect will trigger tick
// Request Permissions strictly on user interaction
requestNotificationPermission();
// Start Worker
if (workerRef.current) {
workerRef.current.postMessage({
type: 'START',
payload: { endTime }
});
}
}, [status, timeLeft, duration]);
const pause = useCallback(() => {
if (status !== 'RUNNING') return;
setStatus('PAUSED');
// Effect calls cancelAnimationFrame
endTimeRef.current = null;
if (workerRef.current) {
workerRef.current.postMessage({ type: 'PAUSE' });
}
}, [status]);
const reset = useCallback((newDuration?: number) => {
@@ -161,8 +246,12 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
setDuration(nextDuration);
setTimeLeft(nextDuration);
setStatus('IDLE');
endTimeRef.current = null;
// Effect calls cancelAnimationFrame (since status becomes IDLE)
if (workerRef.current) {
workerRef.current.postMessage({ type: 'STOP' });
}
}, [duration]);
const addTime = useCallback((seconds: number) => {
@@ -173,7 +262,15 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
// Add to current target
if (endTimeRef.current) {
endTimeRef.current += seconds * 1000;
// Force immediate update to avoid flicker
// Update Worker
if (workerRef.current) {
workerRef.current.postMessage({
type: 'START',
payload: { endTime: endTimeRef.current }
});
}
// Force immediate update locally to avoid flicker
const now = Date.now();
setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)));
}