mirror of
https://github.com/remvze/moodist.git
synced 2025-09-29 15:30:49 -04:00
204 lines
5.4 KiB
TypeScript
204 lines
5.4 KiB
TypeScript
import { useRef, useMemo, useState, useEffect } from 'react';
|
|
import {
|
|
IoPlay,
|
|
IoPause,
|
|
IoRefresh,
|
|
IoTrashOutline,
|
|
} from 'react-icons/io5/index';
|
|
|
|
import { ReverseTimer } from './reverse-timer';
|
|
|
|
import { useCountdownTimers } from '@/stores/countdown-timers';
|
|
import { useAlarm } from '@/hooks/use-alarm';
|
|
import { useSnackbar } from '@/contexts/snackbar';
|
|
import { padNumber } from '@/helpers/number';
|
|
import { cn } from '@/helpers/styles';
|
|
|
|
import styles from './timer.module.css';
|
|
|
|
interface TimerProps {
|
|
id: string;
|
|
}
|
|
|
|
export function Timer({ id }: TimerProps) {
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const lastActiveTimeRef = useRef<number | null>(null);
|
|
const lastStateRef = useRef<{ spent: number; total: number } | null>(null);
|
|
|
|
const [isRunning, setIsRunning] = useState(false);
|
|
|
|
const { name, spent, total } = useCountdownTimers(state =>
|
|
state.getTimer(id),
|
|
) || { name: '', spent: 0, total: 0 };
|
|
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [snapshot, setSnapshot] = useState({ spent: 0, total: 0 });
|
|
|
|
const tick = useCountdownTimers(state => state.tick);
|
|
const rename = useCountdownTimers(state => state.rename);
|
|
const reset = useCountdownTimers(state => state.reset);
|
|
const deleteTimer = useCountdownTimers(state => state.delete);
|
|
|
|
const left = useMemo(
|
|
() => (isDeleting ? snapshot.total - snapshot.spent : total - spent),
|
|
[total, spent, isDeleting, snapshot],
|
|
);
|
|
|
|
const hours = useMemo(() => Math.floor(left / 3600), [left]);
|
|
const minutes = useMemo(() => Math.floor((left % 3600) / 60), [left]);
|
|
const seconds = useMemo(() => left % 60, [left]);
|
|
|
|
const playAlarm = useAlarm();
|
|
|
|
const showSnackbar = useSnackbar();
|
|
|
|
const handleStart = () => {
|
|
if (left > 0) setIsRunning(true);
|
|
};
|
|
|
|
const handlePause = () => setIsRunning(false);
|
|
|
|
const handleToggle = () => {
|
|
if (isRunning) handlePause();
|
|
else handleStart();
|
|
};
|
|
|
|
const handleReset = () => {
|
|
if (spent === 0) return;
|
|
|
|
if (isRunning) return showSnackbar('Please first stop the timer.');
|
|
|
|
setIsRunning(false);
|
|
reset(id);
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (isRunning) return showSnackbar('Please first stop the timer.');
|
|
|
|
setIsDeleting(true);
|
|
setSnapshot({ spent, total });
|
|
|
|
deleteTimer(id);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isRunning) {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
|
|
intervalRef.current = setInterval(() => tick(id), 1000);
|
|
}
|
|
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [isRunning, tick, id]);
|
|
|
|
useEffect(() => {
|
|
if (left === 0 && isRunning) {
|
|
setIsRunning(false);
|
|
playAlarm();
|
|
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
}
|
|
}, [left, isRunning, playAlarm]);
|
|
|
|
useEffect(() => {
|
|
const handleBlur = () => {
|
|
if (isRunning) {
|
|
lastActiveTimeRef.current = Date.now();
|
|
lastStateRef.current = { spent, total };
|
|
}
|
|
};
|
|
|
|
const handleFocus = () => {
|
|
if (isRunning && lastActiveTimeRef.current && lastStateRef.current) {
|
|
const elapsed = Math.floor(
|
|
(Date.now() - lastActiveTimeRef.current) / 1000,
|
|
);
|
|
const previousLeft =
|
|
lastStateRef.current.total - lastStateRef.current.spent;
|
|
const currentLeft = left;
|
|
const correctedLeft = previousLeft - elapsed;
|
|
|
|
if (correctedLeft < currentLeft) {
|
|
tick(id, currentLeft - correctedLeft);
|
|
}
|
|
|
|
lastActiveTimeRef.current = null;
|
|
lastStateRef.current = null;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('blur', handleBlur);
|
|
window.addEventListener('focus', handleFocus);
|
|
|
|
return () => {
|
|
window.removeEventListener('blur', handleBlur);
|
|
window.removeEventListener('focus', handleFocus);
|
|
};
|
|
}, [isRunning, tick, id, spent, total, left]);
|
|
|
|
return (
|
|
<div className={styles.timer} id={`timer-${id}`}>
|
|
<header className={styles.header}>
|
|
<div className={styles.bar}>
|
|
<div
|
|
className={styles.completed}
|
|
style={{ width: `${(left / total) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<ReverseTimer spent={spent} />
|
|
|
|
<div className={styles.left}>
|
|
{padNumber(hours)}
|
|
<span>:</span>
|
|
{padNumber(minutes)}
|
|
<span>:</span>
|
|
{padNumber(seconds)}
|
|
</div>
|
|
|
|
<footer className={styles.footer}>
|
|
<div className={styles.control}>
|
|
<input
|
|
className={cn(styles.input, left === 0 && styles.finished)}
|
|
placeholder="Untitled"
|
|
type="text"
|
|
value={name}
|
|
onChange={e => rename(id, e.target.value)}
|
|
/>
|
|
|
|
<button
|
|
aria-disabled={isRunning || spent === 0}
|
|
className={cn(
|
|
styles.button,
|
|
styles.reset,
|
|
(isRunning || spent === 0) && styles.disabled,
|
|
)}
|
|
onClick={handleReset}
|
|
>
|
|
<IoRefresh />
|
|
</button>
|
|
|
|
<button
|
|
className={styles.button}
|
|
disabled={!isRunning && left === 0}
|
|
onClick={handleToggle}
|
|
>
|
|
{isRunning ? <IoPause /> : <IoPlay />}
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
aria-disabled={isRunning}
|
|
className={cn(styles.delete, isRunning && styles.disabled)}
|
|
onClick={handleDelete}
|
|
>
|
|
<IoTrashOutline />
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|