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)}
+
+
+
+
+ );
+}
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.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 });
+ },
+}));