Timer Signal on Mobile with Notification
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Timer, Play, Pause, RotateCcw, Edit2, Plus, Minus, X, Check } from 'lucide-react';
|
||||
import { Timer, Play, Pause, RotateCcw, Edit2, Plus, Minus, X, Check, Bell, BellOff } from 'lucide-react';
|
||||
import { useRestTimer } from '../../hooks/useRestTimer';
|
||||
import { requestNotificationPermission } from '../../utils/notifications';
|
||||
|
||||
interface RestTimerFABProps {
|
||||
timer: ReturnType<typeof useRestTimer>;
|
||||
@@ -21,6 +22,10 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(120);
|
||||
const [inputValue, setInputValue] = useState(formatSeconds(120));
|
||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>(
|
||||
'Notification' in window ? Notification.permission : 'default'
|
||||
);
|
||||
const [isSecure, setIsSecure] = useState(true);
|
||||
|
||||
// Auto-expand when running if not already expanded? No, requirement says "when time is running, show digits of the countdown on the enlarged timer FAB even if the menu is collapsed".
|
||||
// So the FAB itself grows.
|
||||
@@ -38,6 +43,20 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
||||
setInputValue(formatSeconds(editValue));
|
||||
}, [editValue]);
|
||||
|
||||
// Check permission on mount and focus
|
||||
useEffect(() => {
|
||||
const checkState = () => {
|
||||
if ('Notification' in window) {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
setIsSecure(window.isSecureContext);
|
||||
};
|
||||
|
||||
checkState();
|
||||
window.addEventListener('focus', checkState);
|
||||
return () => window.removeEventListener('focus', checkState);
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isEditing) return; // Don't toggle if editing
|
||||
setIsExpanded(!isExpanded);
|
||||
@@ -54,6 +73,18 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleRequestPermission = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isSecure) {
|
||||
alert("Notifications require a secure context (HTTPS) or localhost.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await requestNotificationPermission();
|
||||
if (result) setNotificationPermission(result);
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const initialVal = timeLeft > 0 ? timeLeft : 120;
|
||||
@@ -189,6 +220,21 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-3 animate-in slide-in-from-bottom-4 fade-in duration-200 mb-4 mr-1">
|
||||
{/* Notification Permission Button (Only if not granted) */}
|
||||
{notificationPermission !== 'granted' && 'Notification' in window && (
|
||||
<button
|
||||
onClick={handleRequestPermission}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-full shadow-elevation-2 transition-all ${isSecure
|
||||
? "bg-tertiary-container text-on-tertiary-container hover:brightness-95 hover:scale-110 animate-pulse"
|
||||
: "bg-surface-container-high text-outline"
|
||||
}`}
|
||||
aria-label={isSecure ? "Enable Notifications" : "Notifications Failed"}
|
||||
title={isSecure ? "Enable Notifications for Timer" : "HTTPS required for notifications"}
|
||||
>
|
||||
{isSecure ? <Bell size={18} /> : <BellOff size={18} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mini FABs */}
|
||||
<button onClick={handleEdit} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface hover:text-primary rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label="Edit">
|
||||
<Edit2 size={18} />
|
||||
|
||||
81
src/hooks/timer.worker.ts
Normal file
81
src/hooks/timer.worker.ts
Normal 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 { };
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
80
src/utils/notifications.ts
Normal file
80
src/utils/notifications.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Request notification permissions from the user.
|
||||
* Safe to call multiple times (idempotent).
|
||||
*/
|
||||
export const requestNotificationPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
console.log('This browser does not support desktop notification');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Current notification permission:', Notification.permission);
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'denied') {
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log('Notification permission request result:', permission);
|
||||
return permission;
|
||||
} catch (e) {
|
||||
console.error('Error requesting notification permission', e);
|
||||
}
|
||||
} else {
|
||||
console.warn('Notification permission is denied. User must enable it in settings.');
|
||||
}
|
||||
return Notification.permission;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a system notification.
|
||||
* @param title Notification title
|
||||
* @param body Notification body text
|
||||
*/
|
||||
export const sendNotification = (title: string, body?: string) => {
|
||||
if (!('Notification' in window)) return;
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
try {
|
||||
// Check if service worker is available for more reliable notifications on mobile
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.ready) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/assets/favicon.svg',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'rest-timer',
|
||||
renotify: true
|
||||
} as any); // Cast to any to allow extended properties
|
||||
});
|
||||
} else {
|
||||
// Fallback to standard notification API
|
||||
new Notification(title, {
|
||||
body,
|
||||
icon: '/assets/favicon.svg',
|
||||
tag: 'rest-timer',
|
||||
renotify: true,
|
||||
vibrate: [200, 100, 200]
|
||||
} as any);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error sending notification', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger device vibration.
|
||||
* @param pattern settings for navigator.vibrate
|
||||
*/
|
||||
export const vibrateDevice = (pattern: number | number[] = [200, 100, 200, 100, 200]) => {
|
||||
if ('vibrate' in navigator) {
|
||||
try {
|
||||
navigator.vibrate(pattern);
|
||||
} catch (e) {
|
||||
console.error('Error vibrating device', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user