39 Commits

Author SHA1 Message Date
MAZE
60cb453847 feat: add shortcut for breathing exercise 2024-07-01 18:54:09 +03:30
MAZE
fc4f52146e feat: add simple breathing exercise tool 2024-07-01 18:50:12 +03:30
MAZE
1a1359c989 fix: icons path 2024-06-25 20:02:19 +04:30
MAZE
a6c7ac41ad feat: replace reverse timer 2024-06-25 20:01:10 +04:30
MAZE
3e11fb6123 feat: add move up and down functionality 2024-06-25 19:56:04 +04:30
MAZE
d356d77aa9 test: write tests for motion lib 2024-06-19 14:26:23 +04:30
MAZE
9cc0ccd325 test: write more tests 2024-06-19 14:23:27 +04:30
MAZE
cad85c7667 test: write tests for random helper 2024-06-19 14:18:47 +04:30
MAZE
def9a57e0c test: add Vitest and some tests 2024-06-19 14:12:06 +04:30
MAZE
74f6b5851d feat: scroll into view after marking favorite 2024-06-17 21:01:53 +04:30
MAZE
f4c66e3092 feat: scroll the new timer into view 2024-06-16 22:22:32 +03:30
MAZE
28abc16b9c style: remove animations 2024-06-16 22:14:47 +03:30
MAZE
787a9b60b5 style: add animation to presets 2024-06-16 22:14:44 +04:30
MAZE
73a5c21be9 chore: add animation to countdown timer 2024-06-16 22:12:12 +04:30
MAZE
cfd2744e92 chore: add toolbox copy 2024-06-16 21:06:26 +04:30
MAZE
4c0f417469 feat: add persist mode to the modal 2024-06-16 19:32:40 +03:30
MAZE
9d1d8f8035 style: change notice 2024-06-16 18:51:23 +03:30
MAZE
8a79ccf018 style: change button style 2024-06-16 18:50:06 +03:30
MAZE
a3c384d105 style: add title to timer 2024-06-16 18:49:15 +03:30
MAZE
96ca376885 style: increase menu width 2024-06-16 18:44:35 +03:30
MAZE
18987cc339 style: add min width 2024-06-16 19:41:09 +04:30
MAZE
919831538f style: change item order 2024-06-16 19:28:29 +04:30
MAZE
edd15f4b9a fix: change shortcuts 2024-06-16 19:27:33 +04:30
MAZE
09c0a6ce93 fix: change icon path 2024-06-16 19:23:54 +04:30
MAZE
2bfb9b181c feat: implement countdown timer functionality 2024-06-16 19:19:22 +04:30
MAZE
c272914416 feat: add basic form 2024-06-16 19:00:38 +04:30
MAZE
d73b2bc1ff refactor: rename components 2024-06-16 18:47:57 +04:30
MAZE
c5657d0642 feat: add countdown timer structure 2024-06-16 18:40:13 +04:30
MAZE
c35409ce0a refactor: separate the migration 2024-06-16 18:09:51 +04:30
MAZE
7658842324 refactor: use the ID instead of index 2024-06-16 17:42:44 +04:30
MAZE
78222be011 feat: add ID to presets 2024-06-16 17:39:44 +04:30
MAZE
2c8135db43 refactor: add description for events 2024-06-15 13:36:10 +04:30
MAZE
fddf75cdca refactor: write JSDoc for libs 2024-06-15 13:32:00 +04:30
MAZE
0f50e6ae8b refactor: add JSDoc for custom hooks 2024-06-15 13:19:00 +04:30
MAZE
4ae0504937 refactor: add JSDoc for helper functions 2024-06-15 13:06:48 +04:30
MAZE
af075b32e6 style: add focus state 2024-06-15 12:58:24 +04:30
MAZE
82d8240b97 feat: add active indicator for sleep timer 2024-06-15 12:55:45 +04:30
MAZE
096251ec0a refactor: change stores structure 2024-06-15 12:44:46 +04:30
MAZE
2a86a88ed6 refactor: rename stores folder 2024-06-15 12:36:47 +04:30
101 changed files with 3983 additions and 1252 deletions

View File

@@ -41,6 +41,7 @@
"sort-destructure-keys/sort-destructure-keys": "warn",
"jsx-a11y/no-static-element-interactions": "off",
"jsx-a11y/media-has-caption": "off",
"jsx-a11y/no-noninteractive-tabindex": "off",
"react/jsx-sort-props": [
"warn",
{

2979
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"test": "vitest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
"lint:fix": "npm run lint -- --fix",
"lint:style": "stylelint ./**/*.{css,astro,html}",
@@ -23,14 +24,15 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@astrojs/react": "^3.0.3",
"@astrojs/react": "3.6.0",
"@floating-ui/react": "0.26.0",
"@formkit/auto-animate": "0.8.2",
"@radix-ui/react-dropdown-menu": "2.0.6",
"@radix-ui/react-tooltip": "1.0.7",
"@types/howler": "2.2.10",
"@types/react": "^18.2.25",
"@types/react-dom": "^18.2.10",
"astro": "4.0.3",
"astro": "4.10.3",
"deepmerge": "4.3.1",
"focus-trap-react": "10.2.3",
"framer-motion": "10.16.4",
@@ -40,6 +42,7 @@
"react-hotkeys-hook": "3.2.1",
"react-icons": "4.11.0",
"react-wrap-balancer": "1.1.0",
"uuid": "10.0.0",
"zustand": "4.4.3"
},
"devDependencies": {
@@ -88,6 +91,7 @@
"stylelint-config-html": "1.1.0",
"stylelint-config-recess-order": "4.4.0",
"stylelint-config-standard": "34.0.0",
"stylelint-prettier": "4.0.2"
"stylelint-prettier": "4.0.2",
"vitest": "1.6.0"
}
}

View File

@@ -18,10 +18,10 @@ const paragraphs = [
body: 'The beauty of Moodist lies in its simplicity and customization. No complex menus or confusing options just choose your desired sounds, adjust the volume balance, and hit play. Want to blend the gentle chirping of birds with the soothing sound of rain? No problem! Layer as many sounds as you like to create your personalized soundscape oasis.',
title: 'Create Your Soundscape',
},
// {
// body: 'Moodist goes beyond just ambient sounds by offering a suite of productivity tools to help you stay organized and focused. Utilize the built-in pomodoro timer to structure your workday in focused intervals, jot down thoughts and ideas in the simple notepad, and keep track of your tasks with the handy to-do list. These tools seamlessly integrate with the ambient soundscapes, allowing you to create a personalized environment that fosters both focus and relaxation.',
// title: 'A Productivity Toolbox',
// },
{
body: 'Moodist offers more than ambient sounds with its suite of productivity tools to keep you organized and focused. Use the built-in pomodoro timer for structured work intervals, jot down ideas in the notepad, track tasks with the to-do list (coming soon), and set multiple timers with the distraction-free countdown timer. These tools integrate seamlessly with the ambient soundscapes, creating a personalized environment that fosters focus and relaxation.',
title: 'A Productivity Toolbox',
},
{
body: "Whether you're looking to unwind after a long day, enhance your focus during work, or lull yourself into a peaceful sleep, Moodist has the perfect soundscape waiting for you. The best part? It's completely free and open-source, so you can enjoy its benefits without any strings attached. Start using Moodist today and discover your new haven of tranquility and focus!",
title: 'Sounds for Every Moment',

View File

@@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow';
import { BiSolidHeart } from 'react-icons/bi/index';
import { Howler } from 'howler';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { Container } from '@/components/container';
import { StoreConsumer } from '@/components/store-consumer';

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect } from 'react';
import { BiPause, BiPlay } from 'react-icons/bi/index';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { useSnackbar } from '@/contexts/snackbar';
import { cn } from '@/helpers/styles';

View File

@@ -4,7 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { Tooltip } from '@/components/tooltip';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { cn } from '@/helpers/styles';
import { fade, mix, slideX } from '@/lib/motion';

View File

@@ -16,7 +16,7 @@ export function Category({
title,
}: CategoryProps) {
return (
<div className={styles.category}>
<div className={styles.category} id={`category-${id}`}>
<div className={styles.iconContainer}>
<div className={styles.tail} />
<div className={styles.icon}>{icon}</div>

View File

@@ -0,0 +1,18 @@
import { IoMdFlower } from 'react-icons/io/index';
import { Item } from '../item';
interface BreathingExerciseProps {
open: () => void;
}
export function BreathingExercise({ open }: BreathingExerciseProps) {
return (
<Item
icon={<IoMdFlower />}
label="Breathing Exercise"
shortcut="Shift + B"
onClick={open}
/>
);
}

View File

@@ -2,12 +2,17 @@ import { MdOutlineTimer } from 'react-icons/md/index';
import { Item } from '../item';
export function CountdownTimer() {
interface SleepTimerProps {
open: () => void;
}
export function CountdownTimer({ open }: SleepTimerProps) {
return (
<Item
href="https://timesy.app"
icon={<MdOutlineTimer />}
label="Countdown Timer"
shortcut="Shift + C"
onClick={open}
/>
);
}

View File

@@ -8,3 +8,4 @@ export { Presets as PresetsItem } from './presets';
export { Shortcuts as ShortcutsItem } from './shortcuts';
export { SleepTimer as SleepTimerItem } from './sleep-timer';
export { CountdownTimer as CountdownTimerItem } from './countdown-timer';
export { BreathingExercise as BreathingExerciseItem } from './breathing-exercise';

View File

@@ -2,7 +2,7 @@ import { MdNotes } from 'react-icons/md/index';
import { Item } from '../item';
import { useNoteStore } from '@/store';
import { useNoteStore } from '@/stores/note';
interface NotepadProps {
open: () => void;

View File

@@ -2,7 +2,7 @@ import { MdOutlineAvTimer } from 'react-icons/md/index';
import { Item } from '../item';
import { usePomodoroStore } from '@/store';
import { usePomodoroStore } from '@/stores/pomodoro';
interface PomodoroProps {
open: () => void;

View File

@@ -2,7 +2,7 @@ import { IoShareSocialSharp } from 'react-icons/io5/index';
import { Item } from '../item';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
interface ShareProps {
open: () => void;

View File

@@ -1,6 +1,6 @@
import { BiShuffle } from 'react-icons/bi/index';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { Item } from '../item';

View File

@@ -1,5 +1,6 @@
import { IoMoonSharp } from 'react-icons/io5/index';
import { useSleepTimerStore } from '@/stores/sleep-timer';
import { Item } from '../item';
interface SleepTimerProps {
@@ -7,8 +8,11 @@ interface SleepTimerProps {
}
export function SleepTimer({ open }: SleepTimerProps) {
const active = useSleepTimerStore(state => state.active);
return (
<Item
active={active}
icon={<IoMoonSharp />}
label="Sleep Timer"
shortcut="Shift + T"

View File

@@ -30,7 +30,7 @@
display: flex;
flex-direction: column;
row-gap: 4px;
width: 250px;
width: 270px;
height: max-content;
max-height: var(--radix-dropdown-menu-content-available-height);
padding: 4px;

View File

@@ -11,19 +11,25 @@ import {
NotepadItem,
SourceItem,
PomodoroItem,
CountdownTimerItem,
PresetsItem,
ShortcutsItem,
SleepTimerItem,
CountdownTimerItem,
BreathingExerciseItem,
} from './items';
import { Divider } from './divider';
import { ShareLinkModal } from '@/components/modals/share-link';
import { PresetsModal } from '@/components/modals/presets';
import { ShortcutsModal } from '@/components/modals/shortcuts';
import { SleepTimerModal } from '@/components/modals/sleep-timer';
import { Notepad, Pomodoro } from '@/components/toolbox';
import {
Notepad,
Pomodoro,
CountdownTimer,
BreathingExercise,
} from '@/components/toolbox';
import { fade, mix, slideY } from '@/lib/motion';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import styles from './menu.module.css';
import { useCloseListener } from '@/hooks/use-close-listener';
@@ -36,6 +42,8 @@ export function Menu() {
const initial = useMemo(
() => ({
breathingExercise: false,
countdownTimer: false,
notepad: false,
pomodoro: false,
presets: false,
@@ -67,6 +75,8 @@ export function Menu() {
useHotkeys('shift+m', () => setIsOpen(prev => !prev));
useHotkeys('shift+n', () => open('notepad'));
useHotkeys('shift+p', () => open('pomodoro'));
useHotkeys('shift+c', () => open('countdownTimer'));
useHotkeys('shift+b', () => open('breathingExercise'));
useHotkeys('shift+alt+p', () => open('presets'));
useHotkeys('shift+h', () => open('shortcuts'));
useHotkeys('shift+s', () => open('shareLink'), { enabled: !noSelected });
@@ -109,9 +119,12 @@ export function Menu() {
<SleepTimerItem open={() => open('sleepTimer')} />
<Divider />
<NotepadItem open={() => open('notepad')} />
<PomodoroItem open={() => open('pomodoro')} />
<CountdownTimerItem />
<NotepadItem open={() => open('notepad')} />
<BreathingExerciseItem
open={() => open('breathingExercise')}
/>
<CountdownTimerItem open={() => open('countdownTimer')} />
<Divider />
<ShortcutsItem open={() => open('shortcuts')} />
@@ -142,6 +155,14 @@ export function Menu() {
show={modals.pomodoro}
onClose={() => close('pomodoro')}
/>
<BreathingExercise
show={modals.breathingExercise}
onClose={() => close('breathingExercise')}
/>
<CountdownTimer
show={modals.countdownTimer}
onClose={() => close('countdownTimer')}
/>
<SleepTimerModal
show={modals.sleepTimer}
onClose={() => close('sleepTimer')}

View File

@@ -14,6 +14,7 @@ interface ModalProps {
children: React.ReactNode;
lockBody?: boolean;
onClose: () => void;
persist?: boolean;
show: boolean;
wide?: boolean;
}
@@ -22,6 +23,7 @@ export function Modal({
children,
lockBody = true,
onClose,
persist = false,
show,
wide,
}: ModalProps) {
@@ -50,40 +52,49 @@ export function Modal({
return () => document.removeEventListener('keydown', keyListener);
}, [onClose, show]);
const animationProps = persist
? {
animate: show ? 'show' : 'hidden',
}
: {
animate: 'show',
exit: 'hidden',
initial: 'hidden',
};
const content = (
<FocusTrap active={show}>
<div>
<motion.div
{...animationProps}
className={styles.overlay}
variants={variants.overlay}
onClick={onClose}
onKeyDown={onClose}
/>
<div className={styles.modal}>
<motion.div
{...animationProps}
className={cn(styles.content, wide && styles.wide)}
variants={variants.modal}
>
<button className={styles.close} onClick={onClose}>
<IoClose />
</button>
{children}
</motion.div>
</div>
</div>
</FocusTrap>
);
return (
<Portal>
<AnimatePresence>
{show && (
<FocusTrap>
<div>
<motion.div
animate="show"
className={styles.overlay}
exit="hidden"
initial="hidden"
variants={variants.overlay}
onClick={onClose}
onKeyDown={onClose}
/>
<div className={styles.modal}>
<motion.div
animate="show"
className={cn(styles.content, wide && styles.wide)}
exit="hidden"
initial="hidden"
variants={variants.modal}
>
<button className={styles.close} onClick={onClose}>
<IoClose />
</button>
{children}
</motion.div>
</div>
</div>
</FocusTrap>
)}
</AnimatePresence>
{persist ? (
<div style={{ display: show ? 'block' : 'none' }}>{content}</div>
) : (
<AnimatePresence>{show && content}</AnimatePresence>
)}
</Portal>
);
}

View File

@@ -2,7 +2,8 @@ import { FaPlay, FaRegTrashAlt } from 'react-icons/fa/index';
import styles from './list.module.css';
import { usePresetStore, useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { usePresetStore } from '@/stores/preset';
interface ListProps {
close: () => void;
@@ -25,15 +26,15 @@ export function List({ close }: ListProps) {
<p className={styles.empty}>You don&apos;t have any presets yet.</p>
)}
{presets.map((preset, index) => (
<div className={styles.preset} key={index}>
{presets.map(preset => (
<div className={styles.preset} key={preset.id}>
<input
placeholder="Untitled"
type="text"
value={preset.label}
onChange={e => changeName(index, e.target.value)}
onChange={e => changeName(preset.id, e.target.value)}
/>
<button onClick={() => deletePreset(index)}>
<button onClick={() => deletePreset(preset.id)}>
<FaRegTrashAlt />
</button>
<button

View File

@@ -1,7 +1,8 @@
import { useState, type FormEvent } from 'react';
import { cn } from '@/helpers/styles';
import { useSoundStore, usePresetStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { usePresetStore } from '@/stores/preset';
import styles from './new.module.css';

View File

@@ -4,7 +4,7 @@ import { IoCopyOutline, IoCheckmark } from 'react-icons/io5/index';
import { Modal } from '@/components/modal';
import { useCopy } from '@/hooks/use-copy';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import styles from './share-link.module.css';

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Modal } from '@/components/modal';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { useSnackbar } from '@/contexts/snackbar';
import { useCloseListener } from '@/hooks/use-close-listener';
import { cn } from '@/helpers/styles';

View File

@@ -29,6 +29,14 @@ export function ShortcutsModal({ onClose, show }: ShortcutsModalProps) {
keys: ['Shift', 'P'],
label: 'Pomodoro Timer',
},
{
keys: ['Shift', 'B'],
label: 'Breathing Exercise',
},
{
keys: ['Shift', 'C'],
label: 'Countdown Timer',
},
{
keys: ['Shift', 'T'],
label: 'Sleep Timer',

View File

@@ -42,6 +42,11 @@
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
outline: none;
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
}
}
}

View File

@@ -3,9 +3,10 @@ import { useEffect, useState, useRef, useMemo } from 'react';
import { Modal } from '@/components/modal';
import { Timer } from '@/components/timer';
import { dispatch } from '@/lib/event';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { cn } from '@/helpers/styles';
import { FADE_OUT } from '@/constants/events';
import { useSleepTimerStore } from '@/stores/sleep-timer';
import styles from './sleep-timer.module.css';
@@ -15,8 +16,12 @@ interface SleepTimerModalProps {
}
export function SleepTimerModal({ onClose, show }: SleepTimerModalProps) {
const setActive = useSleepTimerStore(state => state.set);
const [running, setRunning] = useState(false);
useEffect(() => setActive(running), [running, setActive]);
const [hours, setHours] = useState<string>('0');
const [minutes, setMinutes] = useState<string>('10');

View File

@@ -1,7 +1,7 @@
import { BiShuffle } from 'react-icons/bi/index';
import { Tooltip } from '@/components/tooltip';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import styles from './shuffle.module.css';

View File

@@ -1,13 +1,14 @@
import { BiHeart, BiSolidHeart } from 'react-icons/bi/index';
import { AnimatePresence, motion } from 'framer-motion';
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { cn } from '@/helpers/styles';
import { fade } from '@/lib/motion';
import styles from './favorite.module.css';
import { useKeyboardButton } from '@/hooks/use-keyboard-button';
import { waitUntil } from '@/helpers/wait';
interface FavoriteProps {
id: string;
@@ -18,11 +19,25 @@ export function Favorite({ id, label }: FavoriteProps) {
const isFavorite = useSoundStore(state => state.sounds[id].isFavorite);
const toggleFavorite = useSoundStore(state => state.toggleFavorite);
const handleToggle = async () => {
toggleFavorite(id);
// Check if false -> true
if (!isFavorite) {
await waitUntil(
() => !!document.getElementById('category-favorites'),
50,
);
document
.getElementById('category-favorites')
?.scrollIntoView({ behavior: 'smooth' });
}
};
const variants = fade();
const handleKeyDown = useKeyboardButton(() => {
toggleFavorite(id);
});
const handleKeyDown = useKeyboardButton(handleToggle);
return (
<AnimatePresence initial={false} mode="wait">
@@ -36,7 +51,7 @@ export function Favorite({ id, label }: FavoriteProps) {
onKeyDown={handleKeyDown}
onClick={e => {
e.stopPropagation();
toggleFavorite(id);
handleToggle();
}}
>
<motion.span

View File

@@ -1,4 +1,4 @@
import { useSoundStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import styles from './range.module.css';

View File

@@ -5,7 +5,8 @@ import { Range } from './range';
import { Favorite } from './favorite';
import { useSound } from '@/hooks/use-sound';
import { useSoundStore, useLoadingStore } from '@/store';
import { useSoundStore } from '@/stores/sound';
import { useLoadingStore } from '@/stores/loading';
import { cn } from '@/helpers/styles';
import styles from './sound.module.css';

View File

@@ -1,6 +1,9 @@
import { useEffect } from 'react';
import { useSoundStore, useNoteStore, usePresetStore } from '@/store';
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;
@@ -11,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
useSoundStore.persist.rehydrate();
useNoteStore.persist.rehydrate();
usePresetStore.persist.rehydrate();
useCountdownTimers.persist.rehydrate();
}, []);
return <>{children}</>;

View File

@@ -0,0 +1 @@
/* WIP */

View File

@@ -0,0 +1,18 @@
import { Modal } from '@/components/modal';
import { Exercise } from './exercise';
import styles from './breathing.module.css';
interface TimerProps {
onClose: () => void;
show: boolean;
}
export function BreathingExercise({ onClose, show }: TimerProps) {
return (
<Modal show={show} onClose={onClose}>
<h2 className={styles.title}>Breathing Exercise</h2>
<Exercise />
</Modal>
);
}

View File

@@ -0,0 +1,44 @@
.exercise {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 75px 0;
margin-top: 12px;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
& .phase {
font-family: var(--font-display);
font-size: var(--font-lg);
font-weight: 600;
}
& .circle {
position: absolute;
top: 50%;
left: 50%;
z-index: -1;
height: 55%;
aspect-ratio: 1 / 1;
background-image: radial-gradient(transparent, var(--color-neutral-100));
border: 1px solid var(--color-neutral-200);
border-radius: 50%;
transform: translate(-50%, -50%);
}
}
.selectBox {
width: 100%;
min-width: 0;
height: 45px;
padding: 0 12px;
margin-top: 8px;
font-size: var(--font-sm);
color: var(--color-foreground);
background-color: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
}

View File

@@ -0,0 +1,120 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import styles from './exercise.module.css';
type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing';
type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale';
export function Exercise() {
const [selectedExercise, setSelectedExercise] =
useState<Exercise>('4-7-8 Breathing');
const getAnimationPhases = (
exercise: Exercise,
): Array<'inhale' | 'holdInhale' | 'exhale' | 'holdExhale'> => {
switch (exercise) {
case 'Box Breathing':
return ['inhale', 'holdInhale', 'exhale', 'holdExhale'];
case 'Resonant Breathing':
return ['inhale', 'exhale'];
case '4-7-8 Breathing':
return ['inhale', 'holdInhale', 'exhale'];
default:
return ['inhale', 'holdInhale', 'exhale', 'holdExhale'];
}
};
const getAnimationDurations = (exercise: Exercise) => {
switch (exercise) {
case 'Box Breathing':
return { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 };
case 'Resonant Breathing':
return { exhale: 5, inhale: 5 };
case '4-7-8 Breathing':
return { exhale: 8, holdInhale: 7, inhale: 4 };
default:
return { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 };
}
};
const getLabel = (phase: Phase) => {
switch (phase) {
case 'inhale':
return 'Inhale';
case 'exhale':
return 'Exhale';
default:
return 'Hold';
}
};
const [phase, setPhase] = useState<Phase>('inhale');
const [durations, setDurations] = useState(
getAnimationDurations(selectedExercise),
);
const animationVariants = {
exhale: { scale: 1, transition: { duration: durations.exhale } },
holdExhale: {
scale: 1,
transition: { duration: durations.holdExhale || 4 },
},
holdInhale: {
scale: 1.5,
transition: { duration: durations.holdInhale || 4 },
},
inhale: { scale: 1.5, transition: { duration: durations.inhale } },
};
useEffect(() => {
setDurations(getAnimationDurations(selectedExercise));
}, [selectedExercise]);
useEffect(() => {
const phases = getAnimationPhases(selectedExercise);
let phaseIndex = 0;
setPhase(phases[phaseIndex]);
const interval = setInterval(
() => {
phaseIndex = (phaseIndex + 1) % phases.length;
setPhase(phases[phaseIndex]);
},
(durations[phases[phaseIndex]] || 4) * 1000,
);
return () => clearInterval(interval);
}, [selectedExercise, durations]);
return (
<>
<div className={styles.exercise}>
<motion.div
animate={phase}
className={styles.circle}
key={selectedExercise}
transition={{ ease: 'linear' }}
variants={animationVariants}
transformTemplate={(_, generatedString) =>
`translate(-50%, -50%) ${generatedString}`
}
/>
<p className={styles.phase}>{getLabel(phase)}</p>
</div>
<select
className={styles.selectBox}
value={selectedExercise}
onChange={e => setSelectedExercise(e.target.value as Exercise)}
>
<option value="Box Breathing">Box Breathing</option>
<option value="Resonant Breathing">Resonant Breathing</option>
<option value="4-7-8 Breathing">4-7-8 Breathing</option>
</select>
</>
);
}

View File

@@ -0,0 +1 @@
export { Exercise } from './exercise';

View File

@@ -0,0 +1 @@
export { BreathingExercise } from './breathing';

View File

@@ -0,0 +1,6 @@
.title {
margin-bottom: 16px;
font-family: var(--font-heading);
font-size: var(--font-md);
font-weight: 600;
}

View File

@@ -0,0 +1,25 @@
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { Modal } from '@/components/modal';
import { Form } from './form';
import { Timers } from './timers';
import styles from './countdown-timer.module.css';
interface TimerProps {
onClose: () => void;
show: boolean;
}
export function CountdownTimer({ onClose, show }: TimerProps) {
const [containerRef, enableAnimations] = useAutoAnimate<HTMLDivElement>();
return (
<Modal persist show={show} onClose={onClose}>
<h2 className={styles.title}>Countdown Timer</h2>
<Form enableAnimations={enableAnimations} />
<Timers enableAnimations={enableAnimations} ref={containerRef} />
</Modal>
);
}

View File

@@ -0,0 +1,27 @@
.field {
flex-grow: 1;
& .label {
display: block;
margin-bottom: 8px;
font-size: var(--font-sm);
font-weight: 500;
& .optional {
font-weight: 400;
color: var(--color-foreground-subtle);
}
}
& .input {
width: 100%;
min-width: 0;
height: 40px;
padding: 0 16px;
color: var(--color-foreground);
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
outline: none;
}
}

View File

@@ -0,0 +1,51 @@
import styles from './field.module.css';
interface FieldProps {
children?: React.ReactNode;
label: string;
onChange: (value: string | number) => void;
optional?: boolean;
type: 'text' | 'select';
value: string | number;
}
export function Field({
children,
label,
onChange,
optional,
type,
value,
}: FieldProps) {
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={label.toLowerCase()}>
{label}{' '}
{optional && <span className={styles.optional}>(optional)</span>}
</label>
{type === 'text' && (
<input
autoComplete="off"
className={styles.input}
id={label.toLowerCase()}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
/>
)}
{type === 'select' && (
<select
autoComplete="off"
className={styles.input}
id={label.toLowerCase()}
value={value}
onChange={e => onChange(parseInt(e.target.value))}
>
{children}
</select>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { Field } from './field';

View File

@@ -0,0 +1,27 @@
.form {
display: flex;
flex-direction: column;
row-gap: 28px;
& .button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 45px;
font-weight: 500;
color: var(--color-neutral-50);
cursor: pointer;
background-color: var(--color-neutral-950);
border: none;
border-radius: 4px;
outline: none;
}
}
.timeFields {
display: flex;
column-gap: 12px;
align-items: flex-end;
justify-content: space-between;
}

View File

@@ -0,0 +1,112 @@
import { useState, useMemo } from 'react';
import { Field } from './field';
import { useCountdownTimers } from '@/stores/countdown-timers';
import { waitUntil } from '@/helpers/wait';
import styles from './form.module.css';
interface FormProps {
enableAnimations: (enabled: boolean) => void;
}
export function Form({ enableAnimations }: FormProps) {
const [name, setName] = useState('');
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(10);
const [seconds, setSeconds] = useState(0);
const totalSeconds = useMemo(
() => hours * 60 * 60 + minutes * 60 + seconds,
[hours, minutes, seconds],
);
const add = useCountdownTimers(state => state.add);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (totalSeconds === 0) return;
enableAnimations(false);
const id = add({
name,
total: totalSeconds,
});
setName('');
await waitUntil(() => !!document.getElementById(`timer-${id}`), 50);
document
.getElementById(`timer-${id}`)
?.scrollIntoView({ behavior: 'smooth' });
enableAnimations(true);
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
<Field
label="Timer Name"
optional
type="text"
value={name}
onChange={value => setName(value as string)}
/>
<div className={styles.timeFields}>
<Field
label="Hours"
type="select"
value={hours}
onChange={value => setHours(value as number)}
>
{Array(13)
.fill(null)
.map((_, index) => (
<option key={`hour-${index}`} value={index}>
{index}
</option>
))}
</Field>
<Field
label="Minutes"
type="select"
value={minutes}
onChange={value => setMinutes(value as number)}
>
{Array(60)
.fill(null)
.map((_, index) => (
<option key={`minutes-${index}`} value={index}>
{index}
</option>
))}
</Field>
<Field
label="Seconds"
type="select"
value={seconds}
onChange={value => setSeconds(value as number)}
>
{Array(60)
.fill(null)
.map((_, index) => (
<option key={`seconds-${index}`} value={index}>
{index}
</option>
))}
</Field>
</div>
<button className={styles.button} type="submit">
Add Timer
</button>
</form>
);
}

View File

@@ -0,0 +1 @@
export { Form } from './form';

View File

@@ -0,0 +1 @@
export { CountdownTimer } from './countdown-timer';

View File

@@ -0,0 +1 @@
export { Timers } from './timers';

View File

@@ -0,0 +1 @@
export { Notice } from './notice';

View File

@@ -0,0 +1,11 @@
.notice {
padding: 16px;
margin-top: 16px;
font-size: var(--font-sm);
line-height: 1.65;
color: var(--color-foreground-subtle);
text-align: center;
background-color: var(--color-neutral-50);
border: 1px dashed var(--color-neutral-300);
border-radius: 8px;
}

View File

@@ -0,0 +1,10 @@
import styles from './notice.module.css';
export function Notice() {
return (
<p className={styles.notice}>
Please do not close your browser tab while timers are running, otherwise
all timers will be stopped.
</p>
);
}

View File

@@ -0,0 +1 @@
export { Timer } from './timer';

View File

@@ -0,0 +1,127 @@
.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;
min-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;
cursor: pointer;
& span {
color: var(--color-foreground-subtle);
}
}
}

View File

@@ -0,0 +1,222 @@
import { useRef, useMemo, useState, useEffect } from 'react';
import {
IoPlay,
IoPause,
IoRefresh,
IoTrashOutline,
} from 'react-icons/io5/index';
import { Toolbar } from './toolbar';
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 {
enableAnimations: (enabled: boolean) => void;
id: string;
}
export function Timer({ enableAnimations, 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 { first, last, 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 [isReversed, setIsReversed] = useState(false);
const spentHours = useMemo(() => Math.floor(spent / 3600), [spent]);
const spentMinutes = useMemo(() => Math.floor((spent % 3600) / 60), [spent]);
const spentSeconds = useMemo(() => spent % 60, [spent]);
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.');
enableAnimations(false);
deleteTimer(id);
setTimeout(() => enableAnimations(true), 100);
};
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>
<Toolbar first={first} id={id} last={last} />
<div
className={styles.left}
tabIndex={0}
onClick={() => setIsReversed(prev => !prev)}
onKeyDown={() => setIsReversed(prev => !prev)}
>
{!isReversed ? (
<>
{padNumber(hours)}
<span>:</span>
{padNumber(minutes)}
<span>:</span>
{padNumber(seconds)}
</>
) : (
<>
<span>-</span>
{padNumber(spentHours)}
<span>:</span>
{padNumber(spentMinutes)}
<span>:</span>
{padNumber(spentSeconds)}
</>
)}
</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>
);
}

View File

@@ -0,0 +1 @@
export { Toolbar } from './toolbar';

View File

@@ -0,0 +1,37 @@
.toolbar {
position: absolute;
top: 12px;
right: 12px;
display: flex;
column-gap: 4px;
align-items: center;
height: 30px;
padding: 4px;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
& button {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
aspect-ratio: 1 / 1;
font-size: var(--font-xsm);
color: var(--color-foreground-subtle);
cursor: pointer;
background-color: transparent;
border: none;
transition: 0.2s;
&:disabled {
cursor: not-allowed;
opacity: 0.2;
}
&:not(:disabled):hover {
color: var(--color-foreground);
background-color: var(--color-neutral-100);
}
}
}

View File

@@ -0,0 +1,39 @@
import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io/index';
import { useCountdownTimers } from '@/stores/countdown-timers';
import styles from './toolbar.module.css';
interface ToolbarProps {
first: boolean;
id: string;
last: boolean;
}
export function Toolbar({ first, id, last }: ToolbarProps) {
const moveUp = useCountdownTimers(state => state.moveUp);
const moveDown = useCountdownTimers(state => state.moveDown);
return (
<div className={styles.toolbar}>
<button
disabled={first}
onClick={e => {
e.stopPropagation();
moveUp(id);
}}
>
<IoIosArrowUp />
</button>
<button
disabled={last}
onClick={e => {
e.stopPropagation();
moveDown(id);
}}
>
<IoIosArrowDown />
</button>
</div>
);
}

View File

@@ -0,0 +1,28 @@
.timers {
margin-top: 48px;
& > header {
display: flex;
column-gap: 12px;
align-items: center;
margin-bottom: 16px;
& .title {
font-family: var(--font-heading);
font-size: var(--font-md);
font-weight: 600;
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);
}
}
}

View File

@@ -0,0 +1,55 @@
import { useMemo, forwardRef } from 'react';
import { Timer } from './timer';
import { Notice } from './notice';
import { useCountdownTimers } from '@/stores/countdown-timers';
import styles from './timers.module.css';
interface TimersProps {
enableAnimations: (enabled: boolean) => void;
}
export const Timers = forwardRef(function Timers(
{ enableAnimations }: TimersProps,
ref: React.ForwardedRef<HTMLDivElement>,
) {
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 (
<div>
{timers.length > 0 ? (
<div className={styles.timers}>
<header>
<h2 className={styles.title}>Timers</h2>
<div className={styles.line} />
{totalMinutes > 0 && (
<p className={styles.spent}>
{spentMinutes} / {totalMinutes} Minute
{totalMinutes !== 1 && 's'}
</p>
)}
</header>
<div ref={ref}>
{timers.map(timer => (
<Timer
enableAnimations={enableAnimations}
id={timer.id}
key={timer.id}
/>
))}
</div>
<Notice />
</div>
) : null}
</div>
);
});

View File

@@ -1,2 +1,4 @@
export { Notepad } from './notepad';
export { Pomodoro } from './pomodoro';
export { CountdownTimer } from './countdown-timer';
export { BreathingExercise } from './breathing';

View File

@@ -7,7 +7,7 @@ import { FaUndo } from 'react-icons/fa/index';
import { Modal } from '@/components/modal';
import { Button } from './button';
import { useNoteStore } from '@/store';
import { useNoteStore } from '@/stores/note';
import { useCopy } from '@/hooks/use-copy';
import { download } from '@/helpers/download';

View File

@@ -10,7 +10,7 @@ import { Setting } from './setting';
import { useLocalStorage } from '@/hooks/use-local-storage';
import { useSoundEffect } from '@/hooks/use-sound-effect';
import { usePomodoroStore } from '@/store';
import { usePomodoroStore } from '@/stores/pomodoro';
import { useCloseListener } from '@/hooks/use-close-listener';
import styles from './pomodoro.module.css';

View File

@@ -1,2 +1,2 @@
export const FADE_OUT = 'FADE_OUT';
export const CLOSE_MODALS = 'CLOSE_MODALS';
export const FADE_OUT = 'FADE_OUT'; // Fade out sounds
export const CLOSE_MODALS = 'CLOSE_MODALS'; // Close all modals

View File

@@ -1,3 +1,11 @@
/**
* Counts the number of characters and words in a given string.
*
* @param {string} _string - The input string to be analyzed.
* @returns {{characters: number, words: number}} An object containing the counts:
* - characters: The number of non-whitespace characters in the input string.
* - words: The number of words in the input string.
*/
export function count(_string: string) {
const string = _string.trim();

View File

@@ -1,3 +1,9 @@
/**
* Triggers a download of a file with the specified filename and content.
*
* @param {string} filename - The name of the file to be downloaded.
* @param {string} content - The content to be included in the downloaded file.
*/
export function download(filename: string, content: string) {
const element = document.createElement('a');
element.setAttribute(

View File

@@ -1,3 +1,14 @@
/**
* Pads a given number with leading zeros to ensure it reaches a specified length.
*
* @param {number} number - The number to be padded.
* @param {number} [maxLength=2] - The desired length of the resulting string. Defaults to 2 if not provided.
* @returns {string} The padded number as a string.
*/
export function padNumber(number: number, maxLength: number = 2): string {
return number.toString().padStart(maxLength, '0');
const isNegative = number < 0;
const absoluteNumber = Math.abs(number).toString();
const paddedNumber = absoluteNumber.padStart(maxLength, '0');
return isNegative ? `-${paddedNumber}` : paddedNumber;
}

View File

@@ -1,23 +1,63 @@
/**
* Generates a random number between the specified minimum and maximum values.
*
* @param {number} min - The minimum value (inclusive).
* @param {number} max - The maximum value (exclusive).
* @returns {number} A random number between min (inclusive) and max (exclusive).
*/
export function random(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
/**
* Generates a random integer between the specified minimum and maximum values.
*
* @param {number} min - The minimum value (inclusive).
* @param {number} max - The maximum value (exclusive).
* @returns {number} A random integer between min (inclusive) and max (exclusive).
*/
export function randomInt(min: number, max: number): number {
return Math.floor(random(min, max));
}
/**
* Picks a random element from the given array.
*
* @template T
* @param {Array<T>} array - The array to pick an element from.
* @returns {T} A random element from the array.
*/
export function pick<T>(array: Array<T>): T {
const randomIndex = random(0, array.length);
if (array.length === 0) {
throw new Error("The array shouldn't be empty");
}
const randomIndex = randomInt(0, array.length);
return array[randomIndex];
}
/**
* Picks a specified number of random elements from the given array.
*
* @template T
* @param {Array<T>} array - The array to pick elements from.
* @param {number} count - The number of elements to pick.
* @returns {Array<T>} An array containing the picked elements.
*/
export function pickMany<T>(array: Array<T>, count: number): Array<T> {
const shuffled = shuffle(array);
return shuffled.slice(0, count);
}
/**
* Shuffles the elements of the given array in random order.
*
* @template T
* @param {Array<T>} array - The array to shuffle.
* @returns {Array<T>} The shuffled array.
*/
export function shuffle<T>(array: Array<T>): Array<T> {
return array
.map(value => ({ sort: Math.random(), value }))

View File

@@ -1,5 +1,11 @@
type className = undefined | null | false | string;
/**
* Combines multiple class names into a single string, filtering out invalid values.
*
* @param {...(undefined|null|false|string)} classNames - The class names to be combined.
* @returns {string} A single string containing all valid class names separated by spaces.
*/
export function cn(...classNames: Array<className>): string {
const className = classNames.filter(className => !!className).join(' ');

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { count } from '../counter';
describe('count function', () => {
it('should count characters and words in an empty string', () => {
const result = count('');
expect(result.characters).toBe(0);
expect(result.words).toBe(0);
});
it('should count characters and words in a string with multiple words', () => {
const result = count('Hello world');
expect(result.characters).toBe(10);
expect(result.words).toBe(2);
});
it('should count characters and words in a string with multiple spaces', () => {
const result = count(' Hello world ');
expect(result.characters).toBe(10);
expect(result.words).toBe(2);
});
it('should count characters and words in a string with newlines', () => {
const result = count('Hello\nworld');
expect(result.characters).toBe(10);
expect(result.words).toBe(2);
});
it('should count characters and words in a string with special characters', () => {
const result = count('Hello, world!');
expect(result.characters).toBe(12);
expect(result.words).toBe(2);
});
it('should count characters and words in a string with only spaces', () => {
const result = count(' ');
expect(result.characters).toBe(0);
expect(result.words).toBe(0);
});
it('should count characters and words in a string with a single word', () => {
const result = count('Vitest');
expect(result.characters).toBe(6);
expect(result.words).toBe(1);
});
it('should count characters and words in a string with multiple lines and spaces', () => {
const result = count(' Hello \n world ');
expect(result.characters).toBe(10);
expect(result.words).toBe(2);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { padNumber } from '../number';
describe('padNumber function', () => {
it('should pad a single digit number to two digits by default', () => {
const result = padNumber(5);
expect(result).toBe('05');
});
it('should not pad a number that already has two digits by default', () => {
const result = padNumber(12);
expect(result).toBe('12');
});
it('should pad a number to the specified length', () => {
const result = padNumber(7, 4);
expect(result).toBe('0007');
});
it('should not pad a number that already meets the specified length', () => {
const result = padNumber(1234, 4);
expect(result).toBe('1234');
});
it('should pad a number that has more digits than the specified length', () => {
const result = padNumber(123, 5);
expect(result).toBe('00123');
});
it('should handle zero correctly', () => {
const result = padNumber(0, 3);
expect(result).toBe('000');
});
it('should pad negative numbers correctly', () => {
const result = padNumber(-5, 3);
expect(result).toBe('-005');
});
it('should handle very large padding lengths', () => {
const result = padNumber(42, 10);
expect(result).toBe('0000000042');
});
it('should handle the maximum length being less than the number length', () => {
const result = padNumber(12345, 3);
expect(result).toBe('12345');
});
});

View File

@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { random, randomInt, pick, pickMany, shuffle } from '../random';
describe('random function', () => {
it('should generate a number between min (inclusive) and max (exclusive)', () => {
const min = 1;
const max = 10;
for (let i = 0; i < 100; i++) {
const result = random(min, max);
expect(result).toBeGreaterThanOrEqual(min);
expect(result).toBeLessThan(max);
}
});
});
describe('randomInt function', () => {
it('should generate an integer between min (inclusive) and max (exclusive)', () => {
const min = 1;
const max = 10;
for (let i = 0; i < 100; i++) {
const result = randomInt(min, max);
expect(result).toBeGreaterThanOrEqual(min);
expect(result).toBeLessThan(max);
expect(Number.isInteger(result)).toBe(true);
}
});
});
describe('pick function', () => {
it('should pick a random element from the array', () => {
const array = [1, 2, 3, 4, 5];
const result = pick(array);
expect(array).toContain(result);
});
it('should handle an array with one element', () => {
const array = [1];
const result = pick(array);
expect(result).toBe(1);
});
it('should throw an error when picking from an empty array', () => {
const array: unknown[] = [];
expect(() => pick(array)).toThrow();
});
});
describe('pickMany function', () => {
it('should pick the specified number of random elements from the array', () => {
const array = [1, 2, 3, 4, 5];
const count = 3;
const result = pickMany(array, count);
expect(result).toHaveLength(count);
result.forEach(element => {
expect(array).toContain(element);
});
});
it('should handle picking more elements than in the array', () => {
const array = [1, 2, 3];
const count = 5;
const result = pickMany(array, count);
expect(result).toHaveLength(array.length);
});
});
describe('shuffle function', () => {
it('should shuffle the elements of the array', () => {
const array = [1, 2, 3, 4, 5];
const result = shuffle(array);
expect(result).toHaveLength(array.length);
expect(result).not.toEqual(array); // It's possible for the arrays to be equal, but this is highly unlikely
array.forEach(element => {
expect(result).toContain(element);
});
});
it('should handle an empty array', () => {
const array: unknown[] = [];
const result = shuffle(array);
expect(result).toHaveLength(0);
});
it('should handle an array with one element', () => {
const array = [1];
const result = shuffle(array);
expect(result).toEqual(array);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { cn } from '../styles';
describe('cn function', () => {
it('should return an empty string when no arguments are provided', () => {
const result = cn();
expect(result).toBe('');
});
it('should return an empty string when all arguments are invalid values', () => {
const result = cn(undefined, null, false, '');
expect(result).toBe('');
});
it('should return a single class name when one valid string is provided', () => {
const result = cn('class1');
expect(result).toBe('class1');
});
it('should combine multiple class names into a single string separated by spaces', () => {
const result = cn('class1', 'class2', 'class3');
expect(result).toBe('class1 class2 class3');
});
it('should filter out invalid values and combine valid class names', () => {
const result = cn('class1', undefined, 'class2', null, false, 'class3', '');
expect(result).toBe('class1 class2 class3');
});
it('should handle a mix of valid and invalid class names', () => {
const result = cn('class1', '', false, null, 'class2');
expect(result).toBe('class1 class2');
});
it('should return an empty string when all class names are empty strings', () => {
const result = cn('', '', '');
expect(result).toBe('');
});
it('should handle single class name with leading and trailing spaces', () => {
const result = cn(' class1 ');
expect(result).toBe(' class1 ');
});
it('should handle class names with spaces in between', () => {
const result = cn('class1 class2', 'class3 class4');
expect(result).toBe('class1 class2 class3 class4');
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import { waitUntil } from '../wait';
describe('waitUntil function', () => {
it('should resolve when the function returns true', async () => {
const mockFunc = vi
.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
await waitUntil(mockFunc, 50);
expect(mockFunc).toHaveBeenCalledTimes(3);
});
it('should reject if the function throws an error', async () => {
const mockFunc = vi
.fn()
.mockReturnValueOnce(false)
.mockImplementationOnce(() => {
throw new Error('Test error');
});
await expect(waitUntil(mockFunc, 50)).rejects.toThrow('Test error');
expect(mockFunc).toHaveBeenCalledTimes(2);
});
it('should repeatedly call the function at the specified interval', async () => {
const mockFunc = vi
.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const interval = 100;
const startTime = Date.now();
await waitUntil(mockFunc, interval);
const endTime = Date.now();
const elapsedTime = endTime - startTime;
expect(elapsedTime).toBeGreaterThanOrEqual(2 * interval);
expect(mockFunc).toHaveBeenCalledTimes(3);
});
it('should handle the function returning true on the first call', async () => {
const mockFunc = vi.fn().mockReturnValueOnce(true);
await waitUntil(mockFunc, 50);
expect(mockFunc).toHaveBeenCalledTimes(1);
});
it('should handle the function never returning true (timeout simulation)', async () => {
const mockFunc = vi.fn().mockReturnValue(false);
// Using a very short timeout to simulate test timeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Test timeout')), 300),
);
await expect(
Promise.race([waitUntil(mockFunc, 50), timeoutPromise]),
).rejects.toThrow('Test timeout');
expect(mockFunc).toHaveBeenCalled();
});
});

27
src/helpers/wait.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Repeatedly calls a function at a specified interval until it returns `true`.
*
* @param {() => boolean} func - A function that returns a boolean. The interval will continue until this function returns `true`.
* @param {number} interval - The time, in milliseconds, between each call to `func`.
* @returns {Promise<void>} A promise that resolves when `func` returns `true`, or rejects if an error is thrown during execution of `func`.
*/
export function waitUntil(
func: () => boolean,
interval: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const intervalId = setInterval(() => {
try {
const result = func();
if (result) {
clearInterval(intervalId);
resolve();
}
} catch (error) {
clearInterval(intervalId);
reject(error);
}
}, interval);
});
}

24
src/hooks/use-alarm.ts Normal file
View File

@@ -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;
}

View File

@@ -2,6 +2,11 @@ import { useEffect } from 'react';
import { onCloseModals } from '@/lib/modal';
/**
* A custom React hook that registers a listener function to be called when modals are to be closed.
*
* @param {Function} listener - The function to be called when modals are to be closed.
*/
export function useCloseListener(listener: () => void) {
useEffect(() => {
const unsubscribe = onCloseModals(listener);

View File

@@ -1,5 +1,13 @@
import { useState, useCallback } from 'react';
/**
* A custom React hook to copy text to the clipboard with a temporary state indication.
*
* @param {number} [timeout=1500] - The duration in milliseconds for which the `copying` state remains true. Defaults to 1500 milliseconds.
* @returns {{ copy: (content: string) => void, copying: boolean }} An object containing:
* - copy: The function to copy content to the clipboard.
* - copying: A boolean indicating if a copy operation is in progress.
*/
export function useCopy(timeout = 1500) {
const [copying, setCopying] = useState(false);

View File

@@ -1,6 +1,12 @@
import { useCallback } from 'react';
import type { KeyboardEvent } from 'react';
/**
* A custom React hook that creates a keyboard event handler for 'Enter' and 'Space' keys.
*
* @param {Function} actionCallback - The function to be called when 'Enter' or 'Space' is pressed.
* @returns {Function} A keyboard event handler function that triggers the action callback.
*/
export const useKeyboardButton = (
actionCallback: () => void,
): ((event: KeyboardEvent<HTMLElement>) => void) => {

View File

@@ -2,6 +2,14 @@ import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
type SetValue<T> = Dispatch<SetStateAction<T>>;
/**
* A custom React hook to manage state with localStorage persistence.
*
* @template T
* @param {string} key - The key under which the value is stored in localStorage.
* @param {T} fallback - The fallback value to use if there is no value in localStorage.
* @returns {[T, SetValue<T>]} An array containing the stateful value and a function to update it.
*/
export function useLocalStorage<T>(key: string, fallback: T): [T, SetValue<T>] {
const [value, setValue] = useState(fallback);

View File

@@ -3,6 +3,16 @@ import { Howl } from 'howler';
import { useSSR } from './use-ssr';
/**
* A custom React hook to manage sound effects using Howler.js.
*
* @param {string} src - The source URL of the sound file.
* @param {number} [volume=1] - The initial volume of the sound, ranging from 0.0 to 1.0.
* @returns {{ play: () => void, stop: () => void, pause: () => void }} An object containing control functions for the sound:
* - play: Function to play the sound.
* - stop: Function to stop the sound.
* - pause: Function to pause the sound.
*/
export function useSoundEffect(src: string, volume: number = 1) {
const { isBrowser } = useSSR();

View File

@@ -1,14 +1,33 @@
import { useMemo, useEffect, useCallback, useState } from 'react';
import { Howl } from 'howler';
import { useLoadingStore } from '@/store';
import { useLoadingStore } from '@/stores/loading';
import { subscribe } from '@/lib/event';
import { useSSR } from './use-ssr';
import { FADE_OUT } from '@/constants/events';
/**
* A custom React hook to manage sound playback using Howler.js with additional features.
*
* This hook initializes a Howl instance for playing sound effects in the browser,
* and provides control functions to play, stop, pause, and fade out the sound.
* It also handles loading state management and supports event subscription for fade-out effects.
*
* @param {string} src - The source URL of the sound file.
* @param {Object} [options] - Options for sound playback.
* @param {boolean} [options.loop=false] - Whether the sound should loop.
* @param {number} [options.volume=0.5] - The initial volume of the sound, ranging from 0.0 to 1.0.
* @returns {{ play: () => void, stop: () => void, pause: () => void, fadeOut: (duration: number) => void, isLoading: boolean }} An object containing control functions for the sound:
* - play: Function to play the sound.
* - stop: Function to stop the sound.
* - pause: Function to pause the sound.
* - fadeOut: Function to fade out the sound over a given duration.
* - isLoading: A boolean indicating if the sound is currently loading.
*/
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]);
@@ -20,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) {
@@ -42,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();

View File

@@ -1,3 +1,10 @@
/**
* A custom React hook to determine if the code is running in a browser or server environment.
*
* @returns {{ isBrowser: boolean, isServer: boolean }} An object containing:
* - isBrowser: A boolean indicating if the code is running in a browser environment.
* - isServer: A boolean indicating if the code is running in a server environment.
*/
export function useSSR() {
const isDOM =
typeof window !== 'undefined' &&

View File

@@ -1,9 +1,24 @@
/**
* Dispatches a custom event with an optional detail payload.
*
* @template T
* @param {string} eventName - The name of the event to be dispatched.
* @param {T} [detail] - Optional data to be passed with the event.
*/
export function dispatch<T>(eventName: string, detail?: T) {
const event = new CustomEvent(eventName, { detail });
document.dispatchEvent(event);
}
/**
* Subscribes a listener function to a custom event.
*
* @template T
* @param {string} eventName - The name of the event to listen for.
* @param {(e: T) => void} listener - The function to be called when the event is dispatched.
* @returns {Function} A function to unsubscribe the listener from the event.
*/
export function subscribe<T>(eventName: string, listener: (e: T) => void) {
const handler = (event: Event) => {
if ('detail' in event) {
@@ -18,6 +33,12 @@ export function subscribe<T>(eventName: string, listener: (e: T) => void) {
return () => unsubscribe(eventName, handler);
}
/**
* Unsubscribes a listener function from a custom event.
*
* @param {string} eventName - The name of the event to unsubscribe from.
* @param {(e: Event) => void} listener - The function to be removed from the event listeners.
*/
export function unsubscribe(eventName: string, listener: (e: Event) => void) {
document.removeEventListener(eventName, listener);
}

View File

@@ -1,10 +1,19 @@
import { dispatch, subscribe } from './event';
import { CLOSE_MODALS } from '@/constants/events';
/**
* Dispatches the CLOSE_MODALS event to signal that modals should be closed.
*/
export function closeModals() {
dispatch(CLOSE_MODALS);
}
/**
* Subscribes a listener function to the CLOSE_MODALS event.
*
* @param {() => void} listener - The function to be called when the CLOSE_MODALS event is dispatched.
* @returns {Function} A function to unsubscribe the listener from the CLOSE_MODALS event.
*/
export function onCloseModals(listener: () => void) {
const unsubscribe = subscribe(CLOSE_MODALS, listener);

View File

@@ -7,6 +7,11 @@ type Motion = {
};
};
/**
* Creates a fade motion object with opacity transition.
*
* @returns {Motion} An object containing the hidden and show states for a fade transition.
*/
export function fade(): Motion {
return {
hidden: { opacity: 0 },
@@ -14,6 +19,13 @@ export function fade(): Motion {
};
}
/**
* Creates a scale motion object with scaling transition.
*
* @param {number} [from=0.85] - The initial scale value for the hidden state.
* @param {number} [to=1] - The final scale value for the show state.
* @returns {Motion} An object containing the hidden and show states for a scale transition.
*/
export function scale(from = 0.85, to = 1): Motion {
return {
hidden: { scale: from },
@@ -21,6 +33,13 @@ export function scale(from = 0.85, to = 1): Motion {
};
}
/**
* Creates a slide motion object with horizontal sliding transition.
*
* @param {number} [from=-10] - The initial x position for the hidden state.
* @param {number} [to=0] - The final x position for the show state.
* @returns {Motion} An object containing the hidden and show states for a horizontal slide transition.
*/
export function slideX(from = -10, to = 0): Motion {
return {
hidden: { x: from },
@@ -28,6 +47,13 @@ export function slideX(from = -10, to = 0): Motion {
};
}
/**
* Creates a slide motion object with vertical sliding transition.
*
* @param {number} [from=-10] - The initial y position for the hidden state.
* @param {number} [to=0] - The final y position for the show state.
* @returns {Motion} An object containing the hidden and show states for a vertical slide transition.
*/
export function slideY(from = -10, to = 0): Motion {
return {
hidden: { y: from },
@@ -35,6 +61,15 @@ export function slideY(from = -10, to = 0): Motion {
};
}
/**
* Combines multiple motion objects into a single motion object.
*
* This function merges the hidden and show states of the provided motion objects
* into a single motion object.
*
* @param {...Motion} motions - The motion objects to be combined.
* @returns {Motion} An object containing the combined hidden and show states.
*/
export function mix(...motions: Array<Motion>): Motion {
let hidden = {};
let show = {};

View File

@@ -1,5 +1,11 @@
import { sounds } from '@/data/sounds';
/**
* Counts the total number of sounds across all categories.
*
* @param {boolean} [round=false] - Whether to round the count down to the nearest multiple of 5.
* @returns {number} The total count of sounds, optionally rounded down.
*/
export function count(round: boolean = false) {
let count = 0;

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { fade, scale, slideX, slideY, mix } from '../motion';
describe('fade function', () => {
it('should create a fade motion object with default opacity values', () => {
const result = fade();
expect(result).toEqual({
hidden: { opacity: 0 },
show: { opacity: 1 },
});
});
});
describe('scale function', () => {
it('should create a scale motion object with default values', () => {
const result = scale();
expect(result).toEqual({
hidden: { scale: 0.85 },
show: { scale: 1 },
});
});
it('should create a scale motion object with custom values', () => {
const result = scale(0.5, 1.5);
expect(result).toEqual({
hidden: { scale: 0.5 },
show: { scale: 1.5 },
});
});
});
describe('slideX function', () => {
it('should create a slide motion object with default x values', () => {
const result = slideX();
expect(result).toEqual({
hidden: { x: -10 },
show: { x: 0 },
});
});
it('should create a slide motion object with custom x values', () => {
const result = slideX(-20, 10);
expect(result).toEqual({
hidden: { x: -20 },
show: { x: 10 },
});
});
});
describe('slideY function', () => {
it('should create a slide motion object with default y values', () => {
const result = slideY();
expect(result).toEqual({
hidden: { y: -10 },
show: { y: 0 },
});
});
it('should create a slide motion object with custom y values', () => {
const result = slideY(-20, 10);
expect(result).toEqual({
hidden: { y: -20 },
show: { y: 10 },
});
});
});
describe('mix function', () => {
it('should combine multiple motion objects into a single motion object', () => {
const fadeMotion = fade();
const scaleMotion = scale();
const result = mix(fadeMotion, scaleMotion);
expect(result).toEqual({
hidden: { opacity: 0, scale: 0.85 },
show: { opacity: 1, scale: 1 },
});
});
it('should handle overlapping properties in motion objects', () => {
const slideXMotion = slideX();
const slideYMotion = slideY();
const result = mix(slideXMotion, slideYMotion);
expect(result).toEqual({
hidden: { x: -10, y: -10 },
show: { x: 0, y: 0 },
});
});
it('should handle single motion object', () => {
const fadeMotion = fade();
const result = mix(fadeMotion);
expect(result).toEqual(fadeMotion);
});
it('should handle empty motion objects', () => {
const result = mix();
expect(result).toEqual({ hidden: {}, show: {} });
});
});

View File

@@ -1,5 +0,0 @@
export { useSoundStore } from './sound';
export { useLoadingStore } from './loading';
export { useNoteStore } from './note';
export { usePomodoroStore } from './pomodoro';
export { usePresetStore } from './preset';

View File

@@ -1,52 +0,0 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import merge from 'deepmerge';
interface PresetStore {
addPreset: (label: string, sounds: Record<string, number>) => void;
changeName: (index: number, newName: string) => void;
deletePreset: (index: number) => void;
presets: Array<{
label: string;
sounds: Record<string, number>;
}>;
}
export const usePresetStore = create<PresetStore>()(
persist(
(set, get) => ({
addPreset(label: string, sounds: Record<string, number>) {
set({ presets: [{ label, sounds }, ...get().presets] });
},
changeName(index: number, newName: string) {
const presets = get().presets.map((preset, i) => {
if (i === index) return { ...preset, label: newName };
return preset;
});
set({ presets });
},
deletePreset(index: number) {
set({ presets: get().presets.filter((_, i) => index !== i) });
},
presets: [],
}),
{
merge: (persisted, current) =>
merge(
current,
// @ts-ignore
persisted,
),
name: 'moodist-presets',
partialize: state => ({ presets: state.presets }),
skipHydration: true,
storage: createJSONStorage(() => localStorage),
version: 0,
},
),
);

19
src/stores/alarm/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { create } from 'zustand';
interface AlarmStore {
isPlaying: boolean;
play: () => void;
stop: () => void;
}
export const useAlarmStore = create<AlarmStore>()(set => ({
isPlaying: false,
play() {
set({ isPlaying: true });
},
stop() {
set({ isPlaying: false });
},
}));

View File

@@ -0,0 +1,159 @@
import { v4 as uuid } from 'uuid';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
interface Timer {
id: string;
name: string;
spent: number;
total: number;
}
interface State {
spent: () => number;
timers: Array<Timer>;
total: () => number;
}
interface Actions {
add: (timer: { name: string; total: number }) => string;
delete: (id: string) => void;
getTimer: (id: string) => Timer & { first: boolean; last: boolean };
moveDown: (id: string) => void;
moveUp: (id: string) => void;
rename: (id: string, newName: string) => void;
reset: (id: string) => void;
tick: (id: string, amount?: number) => void;
}
export const useCountdownTimers = create<State & Actions>()(
persist(
(set, get) => ({
add({ name, total }) {
const id = uuid();
set(state => ({
timers: [
{
id,
name,
spent: 0,
total,
},
...state.timers,
],
}));
return id;
},
delete(id) {
set(state => ({
timers: state.timers.filter(timer => timer.id !== id),
}));
},
getTimer(id) {
const timers = get().timers;
const timer = timers.filter(timer => timer.id === id)[0];
const index = timers.indexOf(timer);
return {
...timer,
first: index === 0,
last: index === timers.length - 1,
};
},
moveDown(id) {
set(state => {
const index = state.timers.findIndex(timer => timer.id === id);
if (index < state.timers.length - 1) {
const newTimers = [...state.timers];
[newTimers[index + 1], newTimers[index]] = [
newTimers[index],
newTimers[index + 1],
];
return { timers: newTimers };
}
return state;
});
},
moveUp(id) {
set(state => {
const index = state.timers.findIndex(timer => timer.id === id);
if (index > 0) {
const newTimers = [...state.timers];
[newTimers[index - 1], newTimers[index]] = [
newTimers[index],
newTimers[index - 1],
];
return { timers: newTimers };
}
return state;
});
},
rename(id, newName) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
return { ...timer, name: newName };
}),
}));
},
reset(id) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
return { ...timer, spent: 0 };
}),
}));
},
spent() {
return get().timers.reduce((prev, curr) => prev + curr.spent, 0);
},
tick(id, amount = 1) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
const updatedSpent =
timer.spent + amount > timer.total
? timer.total
: timer.spent + amount;
return { ...timer, spent: updatedSpent };
}),
}));
},
timers: [],
total() {
return get().timers.reduce((prev, curr) => prev + curr.total, 0);
},
}),
{
name: 'moodist-countdown-timers',
partialize: state => ({ timers: state.timers }),
skipHydration: true,
storage: createJSONStorage(() => localStorage),
version: 0,
},
),
);

View File

@@ -0,0 +1,71 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import merge from 'deepmerge';
import { v4 as uuid } from 'uuid';
interface PresetStore {
addPreset: (label: string, sounds: Record<string, number>) => void;
changeName: (id: string, newName: string) => void;
deletePreset: (id: string) => void;
presets: Array<{
id: string;
label: string;
sounds: Record<string, number>;
}>;
}
export const usePresetStore = create<PresetStore>()(
persist(
(set, get) => ({
addPreset(label: string, sounds: Record<string, number>) {
set({ presets: [{ id: uuid(), label, sounds }, ...get().presets] });
},
changeName(id: string, newName: string) {
const presets = get().presets.map(preset => {
if (preset.id === id) return { ...preset, label: newName };
return preset;
});
set({ presets });
},
deletePreset(id: string) {
set({ presets: get().presets.filter(preset => preset.id !== id) });
},
presets: [],
}),
{
merge: (persisted, current) =>
merge(current, persisted as Partial<PresetStore>),
migrate,
name: 'moodist-presets',
partialize: state => ({ presets: state.presets }),
skipHydration: true,
storage: createJSONStorage(() => localStorage),
version: 1,
},
),
);
function migrate(persistedState: unknown, version: number) {
let persisted = persistedState as Partial<PresetStore>;
/**
* In version 0, presets didn't have an ID
*/
if (version < 1) {
persisted = {
...persisted,
presets: (persisted.presets || []).map(preset => {
if (preset.id) return preset;
return { ...preset, id: uuid() };
}),
} as PresetStore;
}
return persisted as PresetStore;
}

View File

@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface SleepTimerStore {
active: boolean;
set: (value: boolean) => void;
}
export const useSleepTimerStore = create<SleepTimerStore>()(set => ({
active: false,
set(value: boolean) {
set({ active: value });
},
}));

Some files were not shown because too many files have changed in this diff Show More