diff --git a/package-lock.json b/package-lock.json
index e17d3a2..1ef4a10 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3602,7 +3602,8 @@
"node_modules/@formkit/auto-animate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.2.tgz",
- "integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ=="
+ "integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==",
+ "license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.11",
diff --git a/src/components/store-consumer/store-consumer.tsx b/src/components/store-consumer/store-consumer.tsx
index 101be03..77b10cc 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 { useTimers } from '@/stores/timers';
interface StoreConsumerProps {
children: React.ReactNode;
@@ -13,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
useSoundStore.persist.rehydrate();
useNoteStore.persist.rehydrate();
usePresetStore.persist.rehydrate();
+ useTimers.persist.rehydrate();
}, []);
return <>{children}>;
diff --git a/src/components/tools/timer/form/field/field.module.css b/src/components/tools/timer/form/field/field.module.css
new file mode 100644
index 0000000..b505d8a
--- /dev/null
+++ b/src/components/tools/timer/form/field/field.module.css
@@ -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-100);
+ border: 1px solid var(--color-neutral-200);
+ border-radius: 8px;
+ outline: none;
+ }
+}
diff --git a/src/components/tools/timer/form/field/field.tsx b/src/components/tools/timer/form/field/field.tsx
new file mode 100644
index 0000000..6b057a8
--- /dev/null
+++ b/src/components/tools/timer/form/field/field.tsx
@@ -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 (
+
+
+
+ {type === 'text' && (
+ onChange(e.target.value)}
+ />
+ )}
+
+ {type === 'select' && (
+
+ )}
+
+ );
+}
diff --git a/src/components/tools/timer/form/field/index.ts b/src/components/tools/timer/form/field/index.ts
new file mode 100644
index 0000000..497b3a7
--- /dev/null
+++ b/src/components/tools/timer/form/field/index.ts
@@ -0,0 +1 @@
+export { Field } from './field';
diff --git a/src/components/tools/timer/form/form.module.css b/src/components/tools/timer/form/form.module.css
new file mode 100644
index 0000000..b6a60ee
--- /dev/null
+++ b/src/components/tools/timer/form/form.module.css
@@ -0,0 +1,28 @@
+.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: 8px;
+ outline: none;
+ box-shadow: inset 0 -3px 0 var(--color-neutral-700);
+ }
+}
+
+.timeFields {
+ display: flex;
+ column-gap: 12px;
+ align-items: flex-end;
+ justify-content: space-between;
+}
diff --git a/src/components/tools/timer/form/form.tsx b/src/components/tools/timer/form/form.tsx
new file mode 100644
index 0000000..748c3a0
--- /dev/null
+++ b/src/components/tools/timer/form/form.tsx
@@ -0,0 +1,97 @@
+import { useState, useMemo } from 'react';
+
+import { Field } from './field';
+
+import { useTimers } from '@/stores/timers';
+
+import styles from './form.module.css';
+
+export function Form() {
+ 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 = useTimers(state => state.add);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (totalSeconds === 0) return;
+
+ add({
+ name,
+ total: totalSeconds,
+ });
+
+ setName('');
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/tools/timer/form/index.ts b/src/components/tools/timer/form/index.ts
new file mode 100644
index 0000000..9398c9e
--- /dev/null
+++ b/src/components/tools/timer/form/index.ts
@@ -0,0 +1 @@
+export { Form } from './form';
diff --git a/src/components/tools/timer/index.ts b/src/components/tools/timer/index.ts
new file mode 100644
index 0000000..91b3f08
--- /dev/null
+++ b/src/components/tools/timer/index.ts
@@ -0,0 +1 @@
+export { Timer } from './timer';
diff --git a/src/components/tools/timer/timer.tsx b/src/components/tools/timer/timer.tsx
new file mode 100644
index 0000000..c7affa1
--- /dev/null
+++ b/src/components/tools/timer/timer.tsx
@@ -0,0 +1,15 @@
+import { Container } from '@/components/container';
+import { Timers } from './timers';
+import { Form } from './form';
+import { StoreConsumer } from '@/components/store-consumer';
+
+export function Timer() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/tools/timer/timers/index.ts b/src/components/tools/timer/timers/index.ts
new file mode 100644
index 0000000..0f9e27a
--- /dev/null
+++ b/src/components/tools/timer/timers/index.ts
@@ -0,0 +1 @@
+export { Timers } from './timers';
diff --git a/src/components/tools/timer/timers/notice/index.ts b/src/components/tools/timer/timers/notice/index.ts
new file mode 100644
index 0000000..64af78d
--- /dev/null
+++ b/src/components/tools/timer/timers/notice/index.ts
@@ -0,0 +1 @@
+export { Notice } from './notice';
diff --git a/src/components/tools/timer/timers/notice/notice.module.css b/src/components/tools/timer/timers/notice/notice.module.css
new file mode 100644
index 0000000..54c58c0
--- /dev/null
+++ b/src/components/tools/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/tools/timer/timers/notice/notice.tsx b/src/components/tools/timer/timers/notice/notice.tsx
new file mode 100644
index 0000000..a25b74c
--- /dev/null
+++ b/src/components/tools/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/tools/timer/timers/timer/index.ts b/src/components/tools/timer/timers/timer/index.ts
new file mode 100644
index 0000000..91b3f08
--- /dev/null
+++ b/src/components/tools/timer/timers/timer/index.ts
@@ -0,0 +1 @@
+export { Timer } from './timer';
diff --git a/src/components/tools/timer/timers/timer/timer.module.css b/src/components/tools/timer/timers/timer/timer.module.css
new file mode 100644
index 0000000..a59151c
--- /dev/null
+++ b/src/components/tools/timer/timers/timer/timer.module.css
@@ -0,0 +1,126 @@
+.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;
+ cursor: pointer;
+
+ & span {
+ color: var(--color-foreground-subtle);
+ }
+ }
+}
diff --git a/src/components/tools/timer/timers/timer/timer.tsx b/src/components/tools/timer/timers/timer/timer.tsx
new file mode 100644
index 0000000..516da01
--- /dev/null
+++ b/src/components/tools/timer/timers/timer/timer.tsx
@@ -0,0 +1,212 @@
+import { useRef, useMemo, useState, useEffect } from 'react';
+import { IoPlay, IoPause, IoRefresh, IoTrashOutline } from 'react-icons/io5';
+
+import { Toolbar } from './toolbar';
+
+import { useTimers } from '@/stores/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 { first, last, name, spent, total } = useTimers(state =>
+ state.getTimer(id),
+ );
+ const tick = useTimers(state => state.tick);
+ const rename = useTimers(state => state.rename);
+ const reset = useTimers(state => state.reset);
+ const deleteTimer = useTimers(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.');
+
+ 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 (
+
+
+
+
+
+
setIsReversed(prev => !prev)}
+ onKeyDown={() => setIsReversed(prev => !prev)}
+ >
+ {!isReversed ? (
+ <>
+ {padNumber(hours)}
+ :
+ {padNumber(minutes)}
+ :
+ {padNumber(seconds)}
+ >
+ ) : (
+ <>
+ -
+ {padNumber(spentHours)}
+ :
+ {padNumber(spentMinutes)}
+ :
+ {padNumber(spentSeconds)}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/tools/timer/timers/timer/toolbar/index.ts b/src/components/tools/timer/timers/timer/toolbar/index.ts
new file mode 100644
index 0000000..dc5abb1
--- /dev/null
+++ b/src/components/tools/timer/timers/timer/toolbar/index.ts
@@ -0,0 +1 @@
+export { Toolbar } from './toolbar';
diff --git a/src/components/tools/timer/timers/timer/toolbar/toolbar.module.css b/src/components/tools/timer/timers/timer/toolbar/toolbar.module.css
new file mode 100644
index 0000000..473234c
--- /dev/null
+++ b/src/components/tools/timer/timers/timer/toolbar/toolbar.module.css
@@ -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);
+ }
+ }
+}
diff --git a/src/components/tools/timer/timers/timer/toolbar/toolbar.tsx b/src/components/tools/timer/timers/timer/toolbar/toolbar.tsx
new file mode 100644
index 0000000..7020ee8
--- /dev/null
+++ b/src/components/tools/timer/timers/timer/toolbar/toolbar.tsx
@@ -0,0 +1,39 @@
+import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io';
+
+import { useTimers } from '@/stores/timers';
+
+import styles from './toolbar.module.css';
+
+interface ToolbarProps {
+ first: boolean;
+ id: string;
+ last: boolean;
+}
+
+export function Toolbar({ first, id, last }: ToolbarProps) {
+ const moveUp = useTimers(state => state.moveUp);
+ const moveDown = useTimers(state => state.moveDown);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/tools/timer/timers/timers.module.css b/src/components/tools/timer/timers/timers.module.css
new file mode 100644
index 0000000..e8cc2c6
--- /dev/null
+++ b/src/components/tools/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/tools/timer/timers/timers.tsx b/src/components/tools/timer/timers/timers.tsx
new file mode 100644
index 0000000..03ebddd
--- /dev/null
+++ b/src/components/tools/timer/timers/timers.tsx
@@ -0,0 +1,46 @@
+import { useMemo } from 'react';
+import { useAutoAnimate } from '@formkit/auto-animate/react';
+
+import { Timer } from './timer';
+import { Notice } from './notice';
+
+import { useTimers } from '@/stores/timers';
+
+import styles from './timers.module.css';
+
+export function Timers() {
+ const [animationParent] = useAutoAnimate();
+ const [animationList] = useAutoAnimate();
+
+ const timers = useTimers(state => state.timers);
+ const spent = useTimers(state => state.spent());
+ const total = useTimers(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..f5ad6ca
--- /dev/null
+++ b/src/hooks/use-alarm.ts
@@ -0,0 +1,20 @@
+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 });
+ 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/pages/tools/countdown-timer.astro b/src/pages/tools/countdown-timer.astro
new file mode 100644
index 0000000..c10446a
--- /dev/null
+++ b/src/pages/tools/countdown-timer.astro
@@ -0,0 +1,21 @@
+---
+import Layout from '@/layouts/layout.astro';
+
+import Donate from '@/components/donate.astro';
+import Hero from '@/components/tools/hero.astro';
+import { Timer } from '@/components/tools/timer';
+import Footer from '@/components/footer.astro';
+import About from '@/components/tools/about.astro';
+import Source from '@/components/source.astro';
+---
+
+
+
+
+
+
+
+
+
diff --git a/src/stores/alarm.ts b/src/stores/alarm.ts
new file mode 100644
index 0000000..2c981d1
--- /dev/null
+++ b/src/stores/alarm.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 });
+ },
+}));
diff --git a/src/stores/timers.ts b/src/stores/timers.ts
new file mode 100644
index 0000000..c5c8e39
--- /dev/null
+++ b/src/stores/timers.ts
@@ -0,0 +1,155 @@
+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;
+ total: () => number;
+}
+
+interface Actions {
+ add: (timer: { name: string; total: number }) => void;
+ 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 useTimers = create()(
+ persist(
+ (set, get) => ({
+ add({ name, total }) {
+ set(state => ({
+ timers: [
+ {
+ id: uuid(),
+ name,
+ spent: 0,
+ total,
+ },
+ ...state.timers,
+ ],
+ }));
+ },
+
+ 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,
+ },
+ ),
+);