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

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; 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 { useRestTimer } from '../../hooks/useRestTimer';
import { requestNotificationPermission } from '../../utils/notifications';
interface RestTimerFABProps { interface RestTimerFABProps {
timer: ReturnType<typeof useRestTimer>; timer: ReturnType<typeof useRestTimer>;
@@ -21,6 +22,10 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(120); const [editValue, setEditValue] = useState(120);
const [inputValue, setInputValue] = useState(formatSeconds(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". // 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. // So the FAB itself grows.
@@ -38,6 +43,20 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
setInputValue(formatSeconds(editValue)); setInputValue(formatSeconds(editValue));
}, [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 = () => { const handleToggle = () => {
if (isEditing) return; // Don't toggle if editing if (isEditing) return; // Don't toggle if editing
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
@@ -54,6 +73,18 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
reset(); 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) => { const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const initialVal = timeLeft > 0 ? timeLeft : 120; const initialVal = timeLeft > 0 ? timeLeft : 120;
@@ -189,6 +220,21 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
</div> </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"> <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 */} {/* 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"> <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} /> <Edit2 size={18} />

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 { useState, useEffect, useRef, useCallback } from 'react';
import { playTimeUpSignal } from '../utils/audio'; import { playTimeUpSignal } from '../utils/audio';
import { requestNotificationPermission, sendNotification, vibrateDevice } from '../utils/notifications';
export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED'; export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED';
@@ -63,35 +64,104 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
const [status, setStatus] = useState<TimerStatus>(initialStatus); const [status, setStatus] = useState<TimerStatus>(initialStatus);
const [duration, setDuration] = useState(initialDuration); 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 endTimeRef = useRef<number | null>(savedState?.endTime || null);
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const prevDefaultTimeRef = useRef(defaultTime); const prevDefaultTimeRef = useRef(defaultTime);
// Tick function - defined before effects // Tick function - defined before effects (RAF version)
const tick = useCallback(() => { const tick = useCallback(() => {
if (!endTimeRef.current) return; if (!endTimeRef.current) return;
const now = Date.now(); const now = Date.now();
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)); 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); setTimeLeft(remaining);
if (remaining <= 0) { if (remaining <= 0) {
// Finished
setStatus('FINISHED'); setStatus('FINISHED');
playTimeUpSignal(); 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 { } else {
rafRef.current = requestAnimationFrame(tick); rafRef.current = requestAnimationFrame(tick);
} }
}, [duration, onFinish]); }, [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 // Save to localStorage whenever relevant state changes
useEffect(() => { useEffect(() => {
const stateToSave = { const stateToSave = {
@@ -143,17 +213,32 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
// If starting from IDLE or PAUSED // If starting from IDLE or PAUSED
const targetSeconds = status === 'PAUSED' ? timeLeft : duration; const targetSeconds = status === 'PAUSED' ? timeLeft : duration;
endTimeRef.current = Date.now() + targetSeconds * 1000; const endTime = Date.now() + targetSeconds * 1000;
endTimeRef.current = endTime;
setStatus('RUNNING'); 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]); }, [status, timeLeft, duration]);
const pause = useCallback(() => { const pause = useCallback(() => {
if (status !== 'RUNNING') return; if (status !== 'RUNNING') return;
setStatus('PAUSED'); setStatus('PAUSED');
// Effect calls cancelAnimationFrame
endTimeRef.current = null; endTimeRef.current = null;
if (workerRef.current) {
workerRef.current.postMessage({ type: 'PAUSE' });
}
}, [status]); }, [status]);
const reset = useCallback((newDuration?: number) => { const reset = useCallback((newDuration?: number) => {
@@ -161,8 +246,12 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
setDuration(nextDuration); setDuration(nextDuration);
setTimeLeft(nextDuration); setTimeLeft(nextDuration);
setStatus('IDLE'); setStatus('IDLE');
endTimeRef.current = null; endTimeRef.current = null;
// Effect calls cancelAnimationFrame (since status becomes IDLE)
if (workerRef.current) {
workerRef.current.postMessage({ type: 'STOP' });
}
}, [duration]); }, [duration]);
const addTime = useCallback((seconds: number) => { const addTime = useCallback((seconds: number) => {
@@ -173,7 +262,15 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
// Add to current target // Add to current target
if (endTimeRef.current) { if (endTimeRef.current) {
endTimeRef.current += seconds * 1000; 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(); const now = Date.now();
setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000))); setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)));
} }

View 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);
}
}
};