diff --git a/src/components/store-consumer/store-consumer.tsx b/src/components/store-consumer/store-consumer.tsx index 101be03..30fe250 100644 --- a/src/components/store-consumer/store-consumer.tsx +++ b/src/components/store-consumer/store-consumer.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { useSoundStore } from '@/stores/sound'; import { useNoteStore } from '@/stores/note'; import { usePresetStore } from '@/stores/preset'; +import { useCountdownTimers } from '@/stores/countdown-timers'; interface StoreConsumerProps { children: React.ReactNode; @@ -13,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) { useSoundStore.persist.rehydrate(); useNoteStore.persist.rehydrate(); usePresetStore.persist.rehydrate(); + useCountdownTimers.persist.rehydrate(); }, []); return <>{children}; diff --git a/src/components/toolbox/countdown-timer/countdown-timer.tsx b/src/components/toolbox/countdown-timer/countdown-timer.tsx index 378066d..3be8a4b 100644 --- a/src/components/toolbox/countdown-timer/countdown-timer.tsx +++ b/src/components/toolbox/countdown-timer/countdown-timer.tsx @@ -1,6 +1,7 @@ import { Modal } from '@/components/modal'; import { Form } from './form'; +import { Timers } from './timers'; interface TimerProps { onClose: () => void; @@ -10,8 +11,8 @@ interface TimerProps { export function CountdownTimer({ onClose, show }: TimerProps) { return ( -

Hello World

+ ); } diff --git a/src/components/toolbox/countdown-timer/timers/index.ts b/src/components/toolbox/countdown-timer/timers/index.ts new file mode 100644 index 0000000..0f9e27a --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/index.ts @@ -0,0 +1 @@ +export { Timers } from './timers'; diff --git a/src/components/toolbox/countdown-timer/timers/notice/index.ts b/src/components/toolbox/countdown-timer/timers/notice/index.ts new file mode 100644 index 0000000..64af78d --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/notice/index.ts @@ -0,0 +1 @@ +export { Notice } from './notice'; diff --git a/src/components/toolbox/countdown-timer/timers/notice/notice.module.css b/src/components/toolbox/countdown-timer/timers/notice/notice.module.css new file mode 100644 index 0000000..54c58c0 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/notice/notice.module.css @@ -0,0 +1,10 @@ +.notice { + padding: 16px; + margin-top: 16px; + font-size: var(--font-sm); + line-height: 1.65; + color: var(--color-foreground-subtle); + text-align: center; + border: 1px dashed var(--color-neutral-200); + border-radius: 8px; +} diff --git a/src/components/toolbox/countdown-timer/timers/notice/notice.tsx b/src/components/toolbox/countdown-timer/timers/notice/notice.tsx new file mode 100644 index 0000000..a25b74c --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/notice/notice.tsx @@ -0,0 +1,10 @@ +import styles from './notice.module.css'; + +export function Notice() { + return ( +

+ Please do not close this tab while timers are running, otherwise all + timers will be stopped. +

+ ); +} diff --git a/src/components/toolbox/countdown-timer/timers/timer/index.ts b/src/components/toolbox/countdown-timer/timers/timer/index.ts new file mode 100644 index 0000000..91b3f08 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/timer/index.ts @@ -0,0 +1 @@ +export { Timer } from './timer'; diff --git a/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/index.ts b/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/index.ts new file mode 100644 index 0000000..03e8ec4 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/index.ts @@ -0,0 +1 @@ +export { ReverseTimer } from './reverse-timer'; diff --git a/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/reverse-timer.module.css b/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/reverse-timer.module.css new file mode 100644 index 0000000..ca62729 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/reverse-timer.module.css @@ -0,0 +1,9 @@ +.reverseTimer { + position: absolute; + top: 15px; + left: 50%; + font-size: var(--font-xsm); + color: var(--color-foreground-subtle); + letter-spacing: 1px; + transform: translate(-50%); +} diff --git a/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/reverse-timer.tsx b/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/reverse-timer.tsx new file mode 100644 index 0000000..5a10532 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/timer/reverse-timer/reverse-timer.tsx @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; + +import { padNumber } from '@/helpers/number'; + +import styles from './reverse-timer.module.css'; + +interface ReverseTimerProps { + spent: number; +} + +export function ReverseTimer({ spent }: ReverseTimerProps) { + const hours = useMemo(() => Math.floor(spent / 3600), [spent]); + const minutes = useMemo(() => Math.floor((spent % 3600) / 60), [spent]); + const seconds = useMemo(() => spent % 60, [spent]); + + return ( +
+ -{padNumber(hours)}:{padNumber(minutes)}:{padNumber(seconds)} +
+ ); +} diff --git a/src/components/toolbox/countdown-timer/timers/timer/timer.module.css b/src/components/toolbox/countdown-timer/timers/timer/timer.module.css new file mode 100644 index 0000000..dbcad09 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/timer/timer.module.css @@ -0,0 +1,125 @@ +.timer { + position: relative; + padding: 8px; + overflow: hidden; + background-color: var(--color-neutral-100); + border: 1px solid var(--color-neutral-200); + border-radius: 8px; + + &:not(:last-of-type) { + margin-bottom: 24px; + } + + & .header { + position: relative; + top: -8px; + width: 100%; + + & .bar { + height: 2px; + margin: 0 -8px; + background-color: var(--color-neutral-200); + + & .completed { + height: 100%; + background-color: var(--color-neutral-500); + transition: 0.2s; + } + } + } + + & .footer { + display: flex; + column-gap: 4px; + align-items: center; + + & .control { + display: flex; + flex-grow: 1; + column-gap: 4px; + align-items: center; + height: 40px; + padding: 4px; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 4px; + + & .input { + flex-grow: 1; + width: 100%; + min-width: 0; + height: 100%; + padding: 0 8px; + color: var(--color-foreground-subtle); + background-color: transparent; + border: none; + border-radius: 4px; + outline: none; + + &.finished { + text-decoration: line-through; + } + } + + & .button { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + aspect-ratio: 1 / 1; + color: var(--color-foreground); + cursor: pointer; + background-color: var(--color-neutral-200); + border: 1px solid var(--color-neutral-300); + border-radius: 2px; + outline: none; + transition: 0.2s; + + &.reset { + background-color: var(--color-neutral-100); + border: none; + } + + &:disabled, + &.disabled { + cursor: not-allowed; + opacity: 0.6; + } + } + } + + & .delete { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + color: #f43f5e; + cursor: pointer; + background-color: rgb(244 63 94 / 10%); + border: none; + border-radius: 4px; + outline: none; + transition: 0.2s; + + &.disabled { + cursor: not-allowed; + opacity: 0.6; + } + } + } + + & .left { + display: flex; + align-items: center; + justify-content: center; + height: 120px; + font-family: var(--font-mono); + font-size: var(--font-2xlg); + font-weight: 700; + + & span { + color: var(--color-foreground-subtle); + } + } +} diff --git a/src/components/toolbox/countdown-timer/timers/timer/timer.tsx b/src/components/toolbox/countdown-timer/timers/timer/timer.tsx new file mode 100644 index 0000000..7bd7107 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/timer/timer.tsx @@ -0,0 +1,188 @@ +import { useRef, useMemo, useState, useEffect } from 'react'; +import { IoPlay, IoPause, IoRefresh, IoTrashOutline } from 'react-icons/io5'; + +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 | null>(null); + const lastActiveTimeRef = useRef(null); + const lastStateRef = useRef<{ spent: number; total: number } | null>(null); + + const [isRunning, setIsRunning] = useState(false); + + const { name, spent, total } = useCountdownTimers(state => + state.getTimer(id), + ); + 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(() => total - spent, [total, spent]); + + 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.'); + + 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 ( +
+
+
+
+
+
+ + + +
+ {padNumber(hours)} + : + {padNumber(minutes)} + : + {padNumber(seconds)} +
+ +
+
+ rename(id, e.target.value)} + /> + + + + +
+ + +
+
+ ); +} diff --git a/src/components/toolbox/countdown-timer/timers/timers.module.css b/src/components/toolbox/countdown-timer/timers/timers.module.css new file mode 100644 index 0000000..e8cc2c6 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/timers.module.css @@ -0,0 +1,27 @@ +.timers { + margin-top: 48px; + + & > header { + display: flex; + column-gap: 12px; + align-items: center; + margin-bottom: 16px; + + & .title { + font-family: var(--font-display); + font-size: var(--font-lg); + line-height: 1; + } + + & .line { + flex-grow: 1; + height: 0; + border-top: 1px dashed var(--color-neutral-200); + } + + & .spent { + font-size: var(--font-sm); + color: var(--color-foreground-subtle); + } + } +} diff --git a/src/components/toolbox/countdown-timer/timers/timers.tsx b/src/components/toolbox/countdown-timer/timers/timers.tsx new file mode 100644 index 0000000..daa35b7 --- /dev/null +++ b/src/components/toolbox/countdown-timer/timers/timers.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; + +import { Timer } from './timer'; +import { Notice } from './notice'; + +import { useCountdownTimers } from '@/stores/countdown-timers'; + +import styles from './timers.module.css'; + +export function Timers() { + const timers = useCountdownTimers(state => state.timers); + const spent = useCountdownTimers(state => state.spent()); + const total = useCountdownTimers(state => state.total()); + + const spentMinutes = useMemo(() => Math.floor(spent / 60), [spent]); + const totalMinutes = useMemo(() => Math.floor(total / 60), [total]); + + return ( +
+ {timers.length > 0 ? ( +
+
+

Timers

+
+ {totalMinutes > 0 && ( +

+ {spentMinutes} / {totalMinutes} Minute + {totalMinutes !== 1 && 's'} +

+ )} +
+ + {timers.map(timer => ( + + ))} + + +
+ ) : null} +
+ ); +} diff --git a/src/hooks/use-alarm.ts b/src/hooks/use-alarm.ts new file mode 100644 index 0000000..b2c8f35 --- /dev/null +++ b/src/hooks/use-alarm.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react'; + +import { useSound } from './use-sound'; +import { useAlarmStore } from '@/stores/alarm'; + +export function useAlarm() { + const { play: playSound } = useSound( + '/sounds/alarm.mp3', + { volume: 1 }, + true, + ); + const isPlaying = useAlarmStore(state => state.isPlaying); + const play = useAlarmStore(state => state.play); + const stop = useAlarmStore(state => state.stop); + + const playAlarm = useCallback(() => { + if (!isPlaying) { + playSound(stop); + play(); + } + }, [isPlaying, playSound, play, stop]); + + return playAlarm; +} diff --git a/src/hooks/use-sound.ts b/src/hooks/use-sound.ts index 88fab70..934092d 100644 --- a/src/hooks/use-sound.ts +++ b/src/hooks/use-sound.ts @@ -26,7 +26,8 @@ import { FADE_OUT } from '@/constants/events'; */ export function useSound( src: string, - options: { loop?: boolean; volume?: number } = {}, + options: { loop?: boolean; preload?: boolean; volume?: number } = {}, + html5: boolean = false, ) { const [hasLoaded, setHasLoaded] = useState(false); const isLoading = useLoadingStore(state => state.loaders[src]); @@ -38,17 +39,18 @@ export function useSound( if (isBrowser) { sound = new Howl({ + html5, onload: () => { setIsLoading(src, false); setHasLoaded(true); }, - preload: false, + preload: options.preload ?? false, src: src, }); } return sound; - }, [src, isBrowser, setIsLoading]); + }, [src, isBrowser, setIsLoading, html5, options.preload]); useEffect(() => { if (sound) { @@ -60,18 +62,23 @@ export function useSound( if (sound) sound.volume(options.volume ?? 0.5); }, [sound, options.volume]); - const play = useCallback(() => { - if (sound) { - if (!hasLoaded && !isLoading) { - setIsLoading(src, true); - sound.load(); - } + const play = useCallback( + (cb?: () => void) => { + if (sound) { + if (!hasLoaded && !isLoading) { + setIsLoading(src, true); + sound.load(); + } - if (!sound.playing()) { - sound.play(); + if (!sound.playing()) { + sound.play(); + } + + if (typeof cb === 'function') sound.once('end', cb); } - } - }, [src, setIsLoading, sound, hasLoaded, isLoading]); + }, + [src, setIsLoading, sound, hasLoaded, isLoading], + ); const stop = useCallback(() => { if (sound) sound.stop(); diff --git a/src/stores/alarm/index.ts b/src/stores/alarm/index.ts new file mode 100644 index 0000000..2c981d1 --- /dev/null +++ b/src/stores/alarm/index.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; + +interface AlarmStore { + isPlaying: boolean; + play: () => void; + stop: () => void; +} + +export const useAlarmStore = create()(set => ({ + isPlaying: false, + + play() { + set({ isPlaying: true }); + }, + + stop() { + set({ isPlaying: false }); + }, +}));