├── .github ├── actions │ └── static-setup │ │ └── action.yml └── workflows │ └── build-pages.yaml ├── .gitignore ├── .npmrc ├── README.md ├── demo └── react │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package.json │ ├── src │ ├── App.module.css │ ├── App.tsx │ ├── components │ │ ├── Separator │ │ │ ├── Separator.module.css │ │ │ └── Separator.tsx │ │ ├── TimeModelExample │ │ │ ├── ChangeDirection.tsx │ │ │ ├── FrequentUpdatesStopwatch.tsx │ │ │ ├── RoundByStopwatch.tsx │ │ │ ├── Stopwatch.tsx │ │ │ └── Timer.tsx │ │ └── TimeModelValueView │ │ │ ├── TimeModelValueView.module.css │ │ │ └── TimeModelValueView.tsx │ ├── index.css │ ├── main.tsx │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── package.json ├── packages └── react-compound-timer │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .npmignore │ ├── .npmrc │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── helpers │ │ ├── getTimeParts.test.ts │ │ ├── getTimeParts.ts │ │ └── now.ts │ ├── hook │ │ └── useTimeModel.ts │ ├── index.ts │ ├── instances │ │ └── timeModel.ts │ ├── models │ │ ├── TimeModel.test.ts │ │ ├── TimeModel.ts │ │ ├── TimeModelState.test.ts │ │ ├── TimeModelState.ts │ │ └── Units.ts │ └── types │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.github/actions/static-setup/action.yml: -------------------------------------------------------------------------------- 1 | name: CI setup for build pages 2 | 3 | runs: 4 | using: composite 5 | steps: 6 | - uses: pnpm/action-setup@v2 7 | with: 8 | version: 8.6.12 9 | - name: "Install Node.js" 10 | uses: actions/setup-node@v3 11 | with: 12 | node-version: 18.15.0 13 | cache: 'pnpm' 14 | - name: "Install root dependencies" 15 | run: pnpm install 16 | shell: bash 17 | -------------------------------------------------------------------------------- /.github/workflows/build-pages.yaml: -------------------------------------------------------------------------------- 1 | name: "Build pages" 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | 7 | permissions: 8 | id-token: write 9 | contents: read 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - name: Setup 20 | uses: ./.github/actions/static-setup 21 | - name: Run tests 22 | run: pnpm --filter react-compound-timer run test 23 | build: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | with: 29 | fetch-depth: 0 30 | - name: Setup 31 | uses: ./.github/actions/static-setup 32 | - name: Run build on library 33 | run: pnpm --filter react-compound-timer run build 34 | - name: Run build on demo react app 35 | run: pnpm --filter demo-react run build 36 | - name: Upload Pages artifact 37 | uses: actions/upload-pages-artifact@v2 38 | with: 39 | path: demo/react/dist 40 | deploy: 41 | # Add a dependency to the build job 42 | needs: build 43 | 44 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 45 | permissions: 46 | pages: write # to deploy to Pages 47 | id-token: write # to verify the deployment originates from an appropriate source 48 | 49 | # Deploy to the github-pages environment 50 | environment: 51 | name: github-pages 52 | url: ${{ steps.deployment.outputs.page_url }} 53 | 54 | # Specify runner + deployment step 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Deploy to GitHub Pages 58 | id: deployment 59 | uses: actions/deploy-pages@v2 # or the latest "vX.X.X" version tag for this action 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | coverage -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Repository for *react-compound-timer* npm package. Contains library source code and demo apps. 2 | 3 | Open [demo](https://volkov97.github.io/react-compound-timer/) to see how it works. 4 | 5 | Navigation: 6 | 7 | - Library source code: [packages/react-compound-timer](https://github.com/volkov97/react-compound-timer/tree/master/packages/react-compound-timer) 8 | - Demo apps source code: [demo/react](https://github.com/volkov97/react-compound-timer/tree/master/demo) -------------------------------------------------------------------------------- /demo/react/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /demo/react/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /demo/react/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /demo/react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-react", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-compound-timer": "workspace:*", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.15", 19 | "@types/react-dom": "^18.2.7", 20 | "@typescript-eslint/eslint-plugin": "^6.0.0", 21 | "@typescript-eslint/parser": "^6.0.0", 22 | "@vitejs/plugin-react-swc": "^3.3.2", 23 | "eslint": "^8.45.0", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.3", 26 | "typescript": "^5.0.2", 27 | "vite": "^4.4.5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/react/src/App.module.css: -------------------------------------------------------------------------------- 1 | .wrap { 2 | display: flex; 3 | flex-direction: column; 4 | } -------------------------------------------------------------------------------- /demo/react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import s from "./App.module.css"; 2 | import { Separator } from "./components/Separator/Separator"; 3 | 4 | import { ChangeDirection } from "./components/TimeModelExample/ChangeDirection"; 5 | import { FrequentUpdatesStopwatch } from "./components/TimeModelExample/FrequentUpdatesStopwatch"; 6 | import { RoundByStopwatch } from "./components/TimeModelExample/RoundByStopwatch"; 7 | import { Stopwatch } from "./components/TimeModelExample/Stopwatch"; 8 | import { Timer } from "./components/TimeModelExample/Timer"; 9 | 10 | function App() { 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | ); 24 | } 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /demo/react/src/components/Separator/Separator.module.css: -------------------------------------------------------------------------------- 1 | .line { 2 | margin: 40px 0; 3 | 4 | width: 100%; 5 | height: 1px; 6 | 7 | background-color: #ccc; 8 | } -------------------------------------------------------------------------------- /demo/react/src/components/Separator/Separator.tsx: -------------------------------------------------------------------------------- 1 | import s from './Separator.module.css'; 2 | 3 | export const Separator = () => { 4 | return
; 5 | }; -------------------------------------------------------------------------------- /demo/react/src/components/TimeModelExample/ChangeDirection.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { createTimeModel, useTimeModel } from "react-compound-timer"; 3 | import { TimeModelValueView } from "../TimeModelValueView/TimeModelValueView"; 4 | 5 | const changeDirectionModel = createTimeModel(); 6 | 7 | export const ChangeDirection = () => { 8 | const { value } = useTimeModel(changeDirectionModel); 9 | 10 | useEffect(() => { 11 | // change direction to backwards after 5 seconds 12 | const timeout = setTimeout(() => { 13 | changeDirectionModel.changeDirection("backward"); 14 | }, 5000); 15 | 16 | return () => clearTimeout(timeout); 17 | }, []); 18 | 19 | return ; 20 | }; 21 | -------------------------------------------------------------------------------- /demo/react/src/components/TimeModelExample/FrequentUpdatesStopwatch.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { createTimeModel, useTimeModel } from "react-compound-timer"; 3 | import { TimeModelValueView } from "../TimeModelValueView/TimeModelValueView"; 4 | 5 | const frequentUpdatesStopwatch = createTimeModel({ 6 | // starting from 1 hour 11 minutes 10 seconds 7 | initialTime: 1 * 60 * 60 * 1000 + 11 * 60 * 1000 + 10 * 1000, 8 | timeToUpdate: 10, 9 | }); 10 | 11 | export const FrequentUpdatesStopwatch = () => { 12 | const { value } = useTimeModel(frequentUpdatesStopwatch); 13 | 14 | useEffect(() => { 15 | // stop after 20 seconds 16 | const timeout = setTimeout(() => { 17 | frequentUpdatesStopwatch.stop(); 18 | }, 60000); 19 | 20 | return () => clearTimeout(timeout); 21 | }, []); 22 | 23 | return ( 24 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /demo/react/src/components/TimeModelExample/RoundByStopwatch.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { createTimeModel, useTimeModel } from "react-compound-timer"; 3 | import { TimeModelValueView } from "../TimeModelValueView/TimeModelValueView"; 4 | 5 | const roundByStopwatch = createTimeModel({ 6 | // starting from 1 hour 11 minutes 10 seconds 7 | initialTime: 1 * 60 * 60 * 1000 + 11 * 60 * 1000 + 10 * 1000, 8 | roundUnit: "s", 9 | timeToUpdate: 10, 10 | }); 11 | 12 | export const RoundByStopwatch = () => { 13 | const { value } = useTimeModel(roundByStopwatch); 14 | 15 | useEffect(() => { 16 | // stop after 20 seconds 17 | const timeout = setTimeout(() => { 18 | roundByStopwatch.stop(); 19 | }, 60000); 20 | 21 | return () => clearTimeout(timeout); 22 | }, []); 23 | 24 | return ( 25 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /demo/react/src/components/TimeModelExample/Stopwatch.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { createTimeModel, useTimeModel } from "react-compound-timer"; 3 | import { TimeModelValueView } from "../TimeModelValueView/TimeModelValueView"; 4 | 5 | // default model acts like simple stopwatch 6 | const stopwatch = createTimeModel(); 7 | 8 | export const Stopwatch = () => { 9 | const { value } = useTimeModel(stopwatch); 10 | 11 | useEffect(() => { 12 | // stop after 20 seconds 13 | const timeout = setTimeout(() => { 14 | stopwatch.stop(); 15 | }, 60000); 16 | 17 | return () => clearTimeout(timeout); 18 | }, []); 19 | 20 | return ; 21 | }; 22 | -------------------------------------------------------------------------------- /demo/react/src/components/TimeModelExample/Timer.tsx: -------------------------------------------------------------------------------- 1 | import { createTimeModel, useTimeModel } from "react-compound-timer"; 2 | import { TimeModelValueView } from "../TimeModelValueView/TimeModelValueView"; 3 | 4 | const timer = createTimeModel({ 5 | initialTime: 10000, 6 | direction: "backward", 7 | }); 8 | 9 | export const Timer = () => { 10 | const { value } = useTimeModel(timer); 11 | 12 | return ; 13 | }; 14 | -------------------------------------------------------------------------------- /demo/react/src/components/TimeModelValueView/TimeModelValueView.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-weight: bold; 3 | font-size: 1.2em; 4 | } 5 | 6 | .description { 7 | margin-bottom: 20px; 8 | } 9 | 10 | .content { 11 | display: flex; 12 | gap: 50px; 13 | } 14 | 15 | .units { 16 | display: flex; 17 | gap: 10px; 18 | flex-wrap: wrap; 19 | } 20 | 21 | .unit { 22 | min-width: 100px; 23 | } 24 | -------------------------------------------------------------------------------- /demo/react/src/components/TimeModelValueView/TimeModelValueView.tsx: -------------------------------------------------------------------------------- 1 | import s from "./TimeModelValueView.module.css"; 2 | import { TimerValue } from 'react-compound-timer'; 3 | 4 | interface TimeModelValueViewProps { 5 | title: string; 6 | description: string; 7 | value: TimerValue; 8 | } 9 | 10 | export const TimeModelValueView: React.FC = ({ 11 | title, 12 | description, 13 | value, 14 | }) => { 15 | return ( 16 |
17 |
{title}
18 |
{description}
19 | 20 |
21 |
{value.state}
22 | 23 |
24 |
25 |
Days
26 |
{value.d}
27 |
28 | 29 |
30 |
Hours
31 |
{value.h}
32 |
33 | 34 |
35 |
Minutes
36 |
{value.m}
37 |
38 | 39 |
40 |
Seconds
41 |
{value.s}
42 |
43 | 44 |
45 |
Milliseconds
46 |
{value.ms}
47 |
48 |
49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /demo/react/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 20px; 4 | 5 | font-family: monospace; 6 | line-height: 1.5; 7 | } -------------------------------------------------------------------------------- /demo/react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /demo/react/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /demo/react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /demo/react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | base: '/react-compound-timer', 7 | plugins: [react()], 8 | }) 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-compound-timer-workspace", 3 | "version": "1.0.0", 4 | "description": "Repository for react-compound-timer", 5 | "keywords": [], 6 | "author": "German Volkov (https://volkov97.com)", 7 | "license": "ISC" 8 | } 9 | -------------------------------------------------------------------------------- /packages/react-compound-timer/.eslintignore: -------------------------------------------------------------------------------- 1 | docs 2 | webpack.config.js 3 | styleguide.config.js 4 | dist 5 | build -------------------------------------------------------------------------------- /packages/react-compound-timer/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "overrides": [], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "react", 20 | "@typescript-eslint" 21 | ], 22 | "rules": {}, 23 | "settings": { 24 | "react": { 25 | "version": "detect" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-compound-timer/.npmignore: -------------------------------------------------------------------------------- 1 | docs 2 | node_modules 3 | demo 4 | coverage 5 | src -------------------------------------------------------------------------------- /packages/react-compound-timer/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true -------------------------------------------------------------------------------- /packages/react-compound-timer/README.md: -------------------------------------------------------------------------------- 1 | # react-compound-timer 2 | 3 | [![version](https://img.shields.io/badge/version-2.0.5-blue)](https://www.npmjs.com/package/react-compound-timer) [![npm type definitions](https://img.shields.io/badge/types-typescript-blue)](https://www.npmjs.com/package/react-compound-timer) [![downloads](https://img.shields.io/badge/downloads-12k%2Fmonth-brightgreen)](https://www.npmjs.com/package/react-compound-timer) [![coverage](https://img.shields.io/badge/coverage-88%25-green)](https://www.npmjs.com/package/react-compound-timer) 4 | 5 | Custom react hook for creating timers, stopwatches, countdowns, etc. 6 | 7 | Demo: https://volkov97.github.io/react-compound-timer/ 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install react-compound-timer 13 | ``` 14 | 15 | ## Usage 16 | 17 | See [demo](https://github.com/volkov97/react-compound-timer/tree/master/demo/react) folder for usage example. 18 | 19 | Examples: 20 | 21 | - [Timer](https://github.com/volkov97/react-compound-timer/blob/master/demo/react/src/components/TimeModelExample/Timer.tsx) 22 | - [Stopwatch](https://github.com/volkov97/react-compound-timer/blob/master/demo/react/src/components/TimeModelExample/Stopwatch.tsx) 23 | - [More examples](https://github.com/volkov97/react-compound-timer/tree/master/demo/react/src/components/TimeModelExample) 24 | 25 | Simple stopwatch: 26 | 27 | ```jsx 28 | import { createTimeModel, useTimeModel } from "react-compound-timer"; 29 | 30 | // Create model, provide your own options object if needed 31 | const stopwatch = createTimeModel(); 32 | 33 | export const Stopwatch = () => { 34 | // Use this model in any components with useTimeModel hook 35 | const { value } = useTimeModel(stopwatch); 36 | 37 | return
{value.s} seconds {value.ms} milliseconds
; 38 | }; 39 | ``` 40 | 41 | You can provide your own options object. See default options [here](https://github.com/volkov97/react-compound-timer/blob/master/packages/react-compound-timer/src/instances/timeModel.ts#L7). 42 | 43 | Simple timer: 44 | 45 | ```jsx 46 | import { createTimeModel, useTimeModel } from "react-compound-timer"; 47 | import { TimeModelValueView } from "../TimeModelValueView/TimeModelValueView"; 48 | 49 | const timer = createTimeModel({ 50 | // start from 10 seconds 51 | initialTime: 10000, 52 | // count down 53 | direction: "backward", 54 | }); 55 | 56 | export const Timer = () => { 57 | const { value } = useTimeModel(timer); 58 | 59 | return
{value.s} seconds {value.ms} milliseconds
; 60 | }; 61 | ``` 62 | 63 | Default options: 64 | 65 | ```js 66 | { 67 | initialTime: 0, 68 | direction: "forward", 69 | timeToUpdate: 250, 70 | startImmediately: true, 71 | lastUnit: "d", 72 | roundUnit: "ms", 73 | checkpoints: [], 74 | } 75 | ``` 76 | -------------------------------------------------------------------------------- /packages/react-compound-timer/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | const config = { 3 | testEnvironment: 'jsdom', 4 | roots: ["/src"], 5 | transform: { 6 | "^.+\\.tsx?$": "ts-jest", 7 | }, 8 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | moduleFileExtensions: ["ts", "tsx", "js", "jsx"], 10 | }; 11 | 12 | module.exports = config; -------------------------------------------------------------------------------- /packages/react-compound-timer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-compound-timer", 3 | "version": "2.0.5", 4 | "description": "React hooks for timers, countdowns, and stopwatches.", 5 | "main": "./dist/cjs/react-compound-timer.cjs", 6 | "module": "./dist/react-compound-timer.legacy-esm.js", 7 | "types": "./dist/react-compound-timer.d.ts", 8 | "exports": { 9 | "./package.json": "./package.json", 10 | ".": { 11 | "types": "./dist/react-compound-timer.d.ts", 12 | "import": "./dist/react-compound-timer.mjs", 13 | "default": "./dist/cjs/react-compound-timer.cjs" 14 | } 15 | }, 16 | "keywords": [ 17 | "react", 18 | "timer", 19 | "countdown", 20 | "compound", 21 | "stopwatch", 22 | "hooks" 23 | ], 24 | "homepage": "https://github.com/volkov97/react-compound-timer", 25 | "scripts": { 26 | "build": "rm -rf dist && tsup", 27 | "check": "npm run typeсheck && npm run lint && npm run test", 28 | "test": "jest --coverage", 29 | "lint": "eslint .", 30 | "typeсheck": "tsc --noEmit", 31 | "prepublishOnly": "npm run check && npm run build" 32 | }, 33 | "author": "German Volkov (https://volkov97.com)", 34 | "license": "ISC", 35 | "devDependencies": { 36 | "@types/jest": "29.5.3", 37 | "@types/react": "18.2.17", 38 | "@types/react-dom": "18.2.7", 39 | "@typescript-eslint/eslint-plugin": "6.2.0", 40 | "@typescript-eslint/parser": "6.2.0", 41 | "eslint": "8.46.0", 42 | "eslint-plugin-react": "7.33.0", 43 | "jest": "29.6.2", 44 | "jest-environment-jsdom": "29.6.2", 45 | "react": "18.2.0", 46 | "react-dom": "18.2.0", 47 | "ts-jest": "29.1.1", 48 | "tsup": "7.2.0", 49 | "typescript": "5.1.6" 50 | }, 51 | "peerDependencies": { 52 | "react": "^16.8.0 || ^17 || ^18" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/helpers/getTimeParts.test.ts: -------------------------------------------------------------------------------- 1 | import { TimeParts, Unit } from '../types'; 2 | 3 | import { getTimeParts } from './getTimeParts'; 4 | 5 | describe('#getTimeParts', () => { 6 | [ 7 | [0, 'h', { h: 0, m: 0, s: 0, ms: 0 }], 8 | [30, 'ms', { ms: 30 }], 9 | [1030, 'ms', { ms: 1030 }], 10 | [1030, 's', { s: 1, ms: 30 }], 11 | [61030, 's', { s: 61, ms: 30 }], 12 | [61030, 'm', { m: 1, s: 1, ms: 30 }], 13 | [3601030, 'm', { m: 60, s: 1, ms: 30 }], 14 | [3661030, 'h', { h: 1, m: 1, s: 1, ms: 30 }], 15 | [86461030, 'h', { h: 24, m: 1, s: 1, ms: 30 }], 16 | [90061030, 'd', { d: 1, h: 1, m: 1, s: 1, ms: 30 }], 17 | ].forEach(([time, lastUnit, result]) => { 18 | it(`should return right result for time = ${time} and lastUnit = ${lastUnit}`, () => { 19 | expect(getTimeParts(time as number, lastUnit as Unit)).toStrictEqual({ 20 | ms: 0, 21 | s: 0, 22 | m: 0, 23 | h: 0, 24 | d: 0, 25 | ...(result as Partial), 26 | }); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/helpers/getTimeParts.ts: -------------------------------------------------------------------------------- 1 | import { Unit, TimeParts } from "../types"; 2 | 3 | export function getTimeParts( 4 | time: number, 5 | lastUnit: Unit, 6 | roundUnit: Unit = "ms" 7 | ): TimeParts { 8 | const units: Unit[] = ["ms", "s", "m", "h", "d"]; 9 | const lastUnitIndex = units.findIndex((unit) => unit === lastUnit); 10 | const roundUnitIndex = units.findIndex((unit) => unit === roundUnit); 11 | 12 | if (roundUnitIndex > lastUnitIndex) { 13 | throw new Error("roundUnitIndex must be less or equal than lastUnitIndex"); 14 | } 15 | 16 | const dividers = [1000, 60, 60, 24, 1]; 17 | const dividersAcc = [1, 1000, 60000, 3600000, 86400000]; 18 | 19 | const startValue = { 20 | ms: 0, 21 | s: 0, 22 | m: 0, 23 | h: 0, 24 | d: 0, 25 | }; 26 | 27 | const output = units.reduce((obj, unit, index) => { 28 | if (index > lastUnitIndex) { 29 | obj[unit] = 0; 30 | } else if (index === lastUnitIndex) { 31 | obj[unit] = Math.floor(time / dividersAcc[index]); 32 | } else { 33 | obj[unit] = Math.floor(time / dividersAcc[index]) % dividers[index]; 34 | } 35 | 36 | return obj; 37 | }, startValue); 38 | 39 | if (roundUnitIndex > 0) { 40 | const unitToDecideOnRoundIndex = roundUnitIndex - 1; 41 | const unitToDecideOnRound = units[unitToDecideOnRoundIndex]; 42 | const isRoundNeeded = output[unitToDecideOnRound] >= dividers[unitToDecideOnRoundIndex] / 2; 43 | 44 | if (isRoundNeeded) { 45 | output[roundUnit] = output[roundUnit] + 1; 46 | } 47 | 48 | for (let i = 0; i < roundUnitIndex; i++) { 49 | output[units[i]] = 0; 50 | } 51 | } 52 | 53 | return output; 54 | } 55 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/helpers/now.ts: -------------------------------------------------------------------------------- 1 | export function now(): number { 2 | // if (typeof window === 'undefined' || !('performance' in window)) { 3 | // return Date.now(); 4 | // } 5 | // return performance.now(); 6 | 7 | // we do not use performance.now() anymore, read links below for more info 8 | // https://medium.com/@mihai/performance-now-sleep-16a5b774560c 9 | // https://persistent.info/web-experiments/performance-now-sleep/ 10 | return Date.now(); 11 | } 12 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/hook/useTimeModel.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { TimeModel } from "../models/TimeModel"; 3 | import { TimerValue } from "../types"; 4 | 5 | export const useTimeModel = (timer: TimeModel) => { 6 | const [timerValue, setTimerValue] = useState(timer.value); 7 | 8 | useEffect(() => { 9 | setTimerValue(timer.value); 10 | 11 | const onChange = (timerValue: TimerValue) => { 12 | setTimerValue(timerValue); 13 | }; 14 | 15 | timer.events = { 16 | ...timer.events, 17 | onChange, 18 | }; 19 | 20 | return () => { 21 | timer.events = { 22 | ...timer.events, 23 | onChange: undefined, 24 | }; 25 | }; 26 | }, [timer]); 27 | 28 | const value = useMemo( 29 | () => ({ 30 | value: timerValue, 31 | changeTime: timer.changeTime, 32 | changeLastUnit: timer.changeLastUnit, 33 | changeTimeToUpdate: timer.changeTimeToUpdate, 34 | changeDirection: timer.changeDirection, 35 | changeCheckpoints: timer.changeCheckpoints, 36 | start: timer.start, 37 | pause: timer.pause, 38 | resume: timer.resume, 39 | stop: timer.stop, 40 | reset: timer.reset, 41 | }), 42 | [timer, timerValue] 43 | ); 44 | 45 | return value; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/index.ts: -------------------------------------------------------------------------------- 1 | import { getTimeParts } from './helpers/getTimeParts'; 2 | import { useTimeModel } from './hook/useTimeModel'; 3 | import { createTimeModel } from './instances/timeModel'; 4 | 5 | export * from './types'; 6 | 7 | export { 8 | createTimeModel, 9 | useTimeModel, 10 | getTimeParts, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/instances/timeModel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TimeModel, 3 | TimeModelEvents, 4 | TimeModelOptions, 5 | } from "../models/TimeModel"; 6 | 7 | const defaultOptions: TimeModelOptions = { 8 | initialTime: 0, 9 | direction: "forward", 10 | timeToUpdate: 250, 11 | startImmediately: true, 12 | lastUnit: "d", 13 | roundUnit: "ms", 14 | checkpoints: [], 15 | }; 16 | 17 | export const createTimeModel = ( 18 | options: Partial = {}, 19 | events: TimeModelEvents = {} 20 | ) => { 21 | const resultOptions = { 22 | ...defaultOptions, 23 | ...options, 24 | }; 25 | 26 | return new TimeModel(resultOptions, events); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/models/TimeModel.test.ts: -------------------------------------------------------------------------------- 1 | import { TimeModel, TimeModelOptions } from "./TimeModel"; 2 | 3 | jest.useFakeTimers(); 4 | 5 | function getDefaultTimerOptions() { 6 | const defaultTimerOptions: TimeModelOptions = { 7 | initialTime: 0, 8 | direction: "forward", 9 | timeToUpdate: 1000, 10 | startImmediately: true, 11 | lastUnit: "d", 12 | roundUnit: "s", 13 | checkpoints: [], 14 | }; 15 | 16 | return defaultTimerOptions; 17 | } 18 | 19 | describe("#TimeModel", () => { 20 | it("should set options", () => { 21 | const model = new TimeModel(getDefaultTimerOptions()); 22 | 23 | expect(model.currentOptions).toEqual(getDefaultTimerOptions()); 24 | }); 25 | 26 | it("should change time", () => { 27 | const model = new TimeModel(getDefaultTimerOptions()); 28 | 29 | model.changeTime(3000); 30 | 31 | expect(model.value).toEqual({ 32 | d: 0, 33 | h: 0, 34 | m: 0, 35 | s: 3, 36 | ms: 0, 37 | state: "PLAYING", 38 | }); 39 | }); 40 | 41 | it("should change lastUnit", () => { 42 | const model = new TimeModel(getDefaultTimerOptions()); 43 | 44 | model.changeLastUnit("m"); 45 | 46 | // change time to 3 days 47 | model.changeTime(259200000); 48 | 49 | expect(model.value).toEqual({ 50 | state: "PLAYING", 51 | d: 0, 52 | h: 0, 53 | m: 4320, 54 | ms: 0, 55 | s: 0, 56 | }); 57 | }); 58 | 59 | it("should change roundUnit", () => { 60 | const model = new TimeModel(getDefaultTimerOptions()); 61 | 62 | model.changeRoundUnit("m"); 63 | 64 | // change time to 3 days and 3 minutes 65 | model.changeTime(259200000 + 180000); 66 | 67 | expect(model.value).toEqual({ 68 | state: "PLAYING", 69 | d: 3, 70 | h: 0, 71 | m: 3, 72 | ms: 0, 73 | s: 0, 74 | }); 75 | }); 76 | 77 | it("should change timeToUpdate", () => { 78 | const onChange = jest.fn(); 79 | 80 | const model = new TimeModel(getDefaultTimerOptions(), { onChange }); 81 | 82 | model.changeTimeToUpdate(2000); 83 | model.start(); 84 | 85 | jest.advanceTimersByTime(1000); 86 | 87 | expect(onChange).toHaveBeenCalledTimes(4); 88 | expect(onChange).lastCalledWith({ 89 | d: 0, 90 | h: 0, 91 | m: 0, 92 | s: 0, 93 | ms: 0, 94 | state: "PLAYING", 95 | }); 96 | 97 | jest.advanceTimersByTime(1500); 98 | 99 | expect(onChange).toHaveBeenCalledTimes(5); 100 | expect(onChange).lastCalledWith({ 101 | d: 0, 102 | h: 0, 103 | m: 0, 104 | s: 2, 105 | ms: 0, 106 | state: "PLAYING", 107 | }); 108 | }); 109 | 110 | describe("events", () => { 111 | it("should call onChange event when time is changed", () => { 112 | const onChange = jest.fn(); 113 | 114 | const model = new TimeModel(getDefaultTimerOptions(), { onChange }); 115 | 116 | model.changeTime(3000); 117 | 118 | expect(onChange).toHaveBeenCalledWith({ 119 | state: "PLAYING", 120 | d: 0, 121 | h: 0, 122 | m: 0, 123 | s: 3, 124 | ms: 0, 125 | }); 126 | }); 127 | 128 | it("should call onStart event when timer is started", () => { 129 | const onStart = jest.fn(); 130 | 131 | const model = new TimeModel(getDefaultTimerOptions(), { onStart }); 132 | 133 | model.start(); 134 | 135 | expect(onStart).toHaveBeenCalled(); 136 | }); 137 | 138 | it("should call onPause event when timer is paused", () => { 139 | const onPause = jest.fn(); 140 | 141 | const model = new TimeModel(getDefaultTimerOptions(), { onPause }); 142 | 143 | model.start(); 144 | model.pause(); 145 | 146 | expect(onPause).toHaveBeenCalled(); 147 | }); 148 | 149 | it("should not call onPause event when timer not started", () => { 150 | const onPause = jest.fn(); 151 | 152 | const model = new TimeModel( 153 | { 154 | ...getDefaultTimerOptions(), 155 | startImmediately: false, 156 | }, 157 | { onPause } 158 | ); 159 | 160 | model.pause(); 161 | 162 | expect(onPause).not.toHaveBeenCalled(); 163 | }); 164 | 165 | it("should not call onPause event when timer is already paused", () => { 166 | const onPause = jest.fn(); 167 | 168 | const model = new TimeModel(getDefaultTimerOptions(), { onPause }); 169 | 170 | model.start(); 171 | model.pause(); 172 | model.pause(); 173 | 174 | expect(onPause).toHaveBeenCalledTimes(1); 175 | }); 176 | 177 | it("should call onResume event when timer is resumed", () => { 178 | const onResume = jest.fn(); 179 | 180 | const model = new TimeModel(getDefaultTimerOptions(), { onResume }); 181 | 182 | model.start(); 183 | model.pause(); 184 | model.resume(); 185 | 186 | expect(onResume).toHaveBeenCalled(); 187 | }); 188 | 189 | it("should not call onResume event when timer not started", () => { 190 | const onResume = jest.fn(); 191 | 192 | const model = new TimeModel(getDefaultTimerOptions(), { onResume }); 193 | 194 | model.resume(); 195 | 196 | expect(onResume).not.toHaveBeenCalled(); 197 | }); 198 | 199 | it("should not call onResume event when timer is not paused", () => { 200 | const onResume = jest.fn(); 201 | 202 | const model = new TimeModel(getDefaultTimerOptions(), { onResume }); 203 | 204 | model.start(); 205 | model.resume(); 206 | 207 | expect(onResume).not.toHaveBeenCalled(); 208 | }); 209 | 210 | it("should call onStop event when timer is stopped", () => { 211 | const onStop = jest.fn(); 212 | 213 | const model = new TimeModel(getDefaultTimerOptions(), { onStop }); 214 | 215 | model.start(); 216 | model.stop(); 217 | 218 | expect(onStop).toHaveBeenCalled(); 219 | }); 220 | 221 | it("should not call onStop event when timer not started", () => { 222 | const onStop = jest.fn(); 223 | 224 | const model = new TimeModel( 225 | { 226 | ...getDefaultTimerOptions(), 227 | startImmediately: false, 228 | }, 229 | { onStop } 230 | ); 231 | 232 | model.stop(); 233 | 234 | expect(onStop).not.toHaveBeenCalled(); 235 | }); 236 | 237 | it("should call onReset event when timer is reset", () => { 238 | const onReset = jest.fn(); 239 | 240 | const model = new TimeModel(getDefaultTimerOptions(), { onReset }); 241 | 242 | model.start(); 243 | model.reset(); 244 | 245 | expect(onReset).toHaveBeenCalled(); 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/models/TimeModel.ts: -------------------------------------------------------------------------------- 1 | import { getTimeParts } from "../helpers/getTimeParts"; 2 | import { now } from "../helpers/now"; 3 | 4 | import { TimeModelState } from "./TimeModelState"; 5 | import { TimerValue, Unit, Checkpoints, Direction } from "../types"; 6 | 7 | export interface TimeModelOptions { 8 | initialTime: number; 9 | startImmediately: boolean; 10 | direction: "forward" | "backward"; 11 | timeToUpdate: number; 12 | lastUnit: Unit; 13 | roundUnit: Unit; 14 | checkpoints: Checkpoints; 15 | } 16 | 17 | export interface TimeModelEvents { 18 | onChange?: (timerValue: TimerValue) => void; 19 | onStart?: () => void; 20 | onResume?: () => void; 21 | onPause?: () => void; 22 | onStop?: () => void; 23 | onReset?: () => void; 24 | } 25 | 26 | export class TimeModel { 27 | private options: TimeModelOptions; 28 | public events: TimeModelEvents; 29 | 30 | private internalTime: number; 31 | private time: number; 32 | private innerState: TimeModelState; 33 | private timerId: number | null; 34 | 35 | constructor(options: TimeModelOptions, events: TimeModelEvents = {}) { 36 | this.internalTime = now(); 37 | this.options = options; 38 | this.events = events; 39 | this.time = options.initialTime; 40 | this.innerState = new TimeModelState("INITED", () => { 41 | if (this.events.onChange) { 42 | this.events.onChange(this.value); 43 | } 44 | }); 45 | 46 | this.timerId = null; 47 | 48 | if (this.options.startImmediately) { 49 | this.start(); 50 | } 51 | } 52 | 53 | get value() { 54 | return this.getTimerValue(this.computeTime()); 55 | } 56 | 57 | get currentOptions(): TimeModelOptions { 58 | return JSON.parse(JSON.stringify(this.options)); 59 | } 60 | 61 | /** 62 | * Change options methods 63 | **/ 64 | 65 | public changeTime = (time: number) => { 66 | this.internalTime = now(); 67 | this.options.initialTime = time; 68 | this.time = this.options.initialTime; 69 | 70 | if (this.events.onChange) { 71 | this.events.onChange(this.getTimerValue(this.time)); 72 | } 73 | }; 74 | 75 | public changeLastUnit = (lastUnit: Unit) => { 76 | if (this.innerState.isPlaying()) { 77 | this.pause(); 78 | this.options.lastUnit = lastUnit; 79 | this.resume(true); 80 | } else { 81 | this.options.lastUnit = lastUnit; 82 | } 83 | }; 84 | 85 | public changeRoundUnit = (roundUnit: Unit) => { 86 | if (this.innerState.isPlaying()) { 87 | this.pause(); 88 | this.options.roundUnit = roundUnit; 89 | this.resume(true); 90 | } else { 91 | this.options.roundUnit = roundUnit; 92 | } 93 | }; 94 | 95 | public changeTimeToUpdate = (interval: number) => { 96 | if (this.innerState.isPlaying()) { 97 | this.pause(); 98 | this.options.timeToUpdate = interval; 99 | this.resume(); 100 | } else { 101 | this.options.timeToUpdate = interval; 102 | } 103 | }; 104 | 105 | public changeDirection = (direction: Direction) => { 106 | this.options.direction = direction; 107 | }; 108 | 109 | public changeCheckpoints = (checkpoints: Checkpoints) => { 110 | this.options.checkpoints = checkpoints; 111 | }; 112 | 113 | /** 114 | * Timer controls methods 115 | **/ 116 | 117 | public start = () => { 118 | if (this.innerState.setPlaying()) { 119 | this.setTimerInterval(true); 120 | 121 | if (this.events.onStart) { 122 | this.events.onStart(); 123 | } 124 | } 125 | }; 126 | 127 | public resume = (callImmediately = false) => { 128 | if (this.innerState.isPaused() && this.innerState.setPlaying()) { 129 | this.setTimerInterval(callImmediately); 130 | 131 | if (this.events.onResume) { 132 | this.events.onResume(); 133 | } 134 | } 135 | }; 136 | 137 | public pause = () => { 138 | if (this.innerState.setPaused()) { 139 | if (this.timerId) { 140 | clearInterval(this.timerId); 141 | } 142 | 143 | if (this.events.onPause) { 144 | this.events.onPause(); 145 | } 146 | } 147 | }; 148 | 149 | public stop = () => { 150 | if (this.innerState.setStopped()) { 151 | if (this.timerId) { 152 | clearInterval(this.timerId); 153 | } 154 | 155 | if (this.events.onStop) { 156 | this.events.onStop(); 157 | } 158 | } 159 | }; 160 | 161 | public reset = () => { 162 | this.time = this.options.initialTime; 163 | 164 | if (this.events.onChange) { 165 | this.events.onChange(this.getTimerValue(this.time)); 166 | } 167 | 168 | if (this.events.onReset) { 169 | this.events.onReset(); 170 | } 171 | }; 172 | 173 | /** 174 | * Private methods 175 | **/ 176 | 177 | private setTimerInterval = (callImmediately = false) => { 178 | if (this.timerId) { 179 | clearInterval(this.timerId); 180 | } 181 | 182 | this.internalTime = now(); 183 | 184 | const repeatedFunc = () => { 185 | const oldTime = this.time; 186 | const updatedTime = this.computeTime(); 187 | 188 | if (this.events.onChange) { 189 | this.events.onChange(this.getTimerValue(updatedTime)); 190 | } 191 | 192 | this.options.checkpoints.map(({ time, callback }) => { 193 | const checkForForward = time > oldTime && time <= updatedTime; 194 | const checkForBackward = time < oldTime && time >= updatedTime; 195 | const checkIntersection = 196 | this.options.direction === "backward" 197 | ? checkForBackward 198 | : checkForForward; 199 | 200 | if (checkIntersection) { 201 | callback(); 202 | } 203 | }); 204 | }; 205 | 206 | if (callImmediately && this.events.onChange) { 207 | this.events.onChange(this.getTimerValue(this.time)); 208 | } 209 | 210 | this.timerId = window.setInterval(repeatedFunc, this.options.timeToUpdate); 211 | }; 212 | 213 | private getTimerValue = (time: number): TimerValue => { 214 | return { 215 | ...getTimeParts(time, this.options.lastUnit, this.options.roundUnit), 216 | state: this.innerState.getState(), 217 | }; 218 | }; 219 | 220 | private computeTime = () => { 221 | if (this.innerState.isPlaying()) { 222 | const currentInternalTime = now(); 223 | const delta = Math.abs(currentInternalTime - this.internalTime); 224 | 225 | switch (this.options.direction) { 226 | case "forward": 227 | this.time = this.time + delta; 228 | this.internalTime = currentInternalTime; 229 | 230 | return this.time; 231 | 232 | case "backward": { 233 | this.time = this.time - delta; 234 | this.internalTime = currentInternalTime; 235 | 236 | if (this.time < 0) { 237 | this.stop(); 238 | 239 | return 0; 240 | } 241 | 242 | return this.time; 243 | } 244 | 245 | default: 246 | return this.time; 247 | } 248 | } 249 | 250 | return this.time < 0 ? 0 : this.time; 251 | }; 252 | } 253 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/models/TimeModelState.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TimeModelState, 3 | INITED, 4 | PLAYING, 5 | PAUSED, 6 | STOPPED, 7 | } from "./TimeModelState"; 8 | 9 | describe("#TimeModelState", () => { 10 | it("should set INITED state in constructor", () => { 11 | const state = new TimeModelState(); 12 | 13 | expect(state.getState()).toEqual(INITED); 14 | }); 15 | 16 | it("should return false when set INITED from INITED", () => { 17 | const state = new TimeModelState(); 18 | 19 | expect(state.setInited()).toEqual(false); 20 | }); 21 | 22 | it("should return true when set INITED from PLAYING", () => { 23 | const state = new TimeModelState(); 24 | 25 | state.setPlaying(); 26 | 27 | expect(state.setInited()).toEqual(true); 28 | }); 29 | 30 | it("should return false when set PLAYING from PLAYING", () => { 31 | const state = new TimeModelState(); 32 | 33 | state.setPlaying(); 34 | 35 | expect(state.setPlaying()).toEqual(false); 36 | }); 37 | 38 | it("should return true when set PLAYING from INITED", () => { 39 | const state = new TimeModelState(); 40 | 41 | expect(state.setPlaying()).toEqual(true); 42 | }); 43 | 44 | it("should return true when set PAUSED from PLAYING", () => { 45 | const state = new TimeModelState(); 46 | 47 | state.setPlaying(); 48 | 49 | expect(state.setPaused()).toEqual(true); 50 | }); 51 | 52 | it("should return false when set INITED from PAUSED", () => { 53 | const state = new TimeModelState(); 54 | 55 | expect(state.setPaused()).toEqual(false); 56 | }); 57 | 58 | it("should return false when set STOPPED from INITED", () => { 59 | const state = new TimeModelState(); 60 | 61 | expect(state.setStopped()).toEqual(false); 62 | }); 63 | 64 | it("should return true when set PLAYING from STOPPED", () => { 65 | const state = new TimeModelState(); 66 | 67 | state.setPlaying(); 68 | 69 | expect(state.setStopped()).toEqual(true); 70 | }); 71 | 72 | it("should set PLAYING state", () => { 73 | const state = new TimeModelState(); 74 | 75 | state.setPlaying(); 76 | 77 | expect(state.getState()).toEqual(PLAYING); 78 | }); 79 | 80 | it("should set INITED state", () => { 81 | const state = new TimeModelState(); 82 | 83 | state.setPlaying(); 84 | state.setInited(); 85 | 86 | expect(state.getState()).toEqual(INITED); 87 | }); 88 | 89 | it("should not set PAUSED state when not in PLAYING", () => { 90 | const state = new TimeModelState(); 91 | 92 | state.setPaused(); 93 | 94 | expect(state.getState()).not.toEqual(PAUSED); 95 | expect(state.getState()).toEqual(INITED); 96 | }); 97 | 98 | it("should set PAUSED state when in PLAYING", () => { 99 | const state = new TimeModelState(); 100 | 101 | state.setPlaying(); 102 | state.setPaused(); 103 | 104 | expect(state.getState()).toEqual(PAUSED); 105 | }); 106 | 107 | it("should not set STOPPED state when in INITED", () => { 108 | const state = new TimeModelState(); 109 | 110 | state.setStopped(); 111 | 112 | expect(state.getState()).not.toEqual(STOPPED); 113 | expect(state.getState()).toEqual(INITED); 114 | }); 115 | 116 | it("should set STOPPED state when not in INITED", () => { 117 | const state = new TimeModelState(); 118 | 119 | state.setPlaying(); 120 | state.setStopped(); 121 | 122 | expect(state.getState()).toEqual(STOPPED); 123 | }); 124 | 125 | it("should call onChange handler with INITED timer state on setInited", () => { 126 | const mockCallback = jest.fn(({ state }) => state); 127 | const state = new TimeModelState("INITED", mockCallback); 128 | 129 | state.setPlaying(); 130 | state.setInited(); 131 | 132 | expect(mockCallback.mock.results[1].value).toEqual(INITED); 133 | }); 134 | 135 | it("should call onChange handler with PLAYING timer state on setPlaying", () => { 136 | const mockCallback = jest.fn(({ state }) => state); 137 | const state = new TimeModelState("INITED", mockCallback); 138 | 139 | state.setPlaying(); 140 | 141 | expect(mockCallback.mock.results[0].value).toEqual(PLAYING); 142 | }); 143 | 144 | it("should call onChange handler with PAUSED timer state on setPaused", () => { 145 | const mockCallback = jest.fn(({ state }) => state); 146 | const state = new TimeModelState("INITED", mockCallback); 147 | 148 | state.setPlaying(); 149 | state.setPaused(); 150 | 151 | expect(mockCallback.mock.results[1].value).toEqual(PAUSED); 152 | }); 153 | 154 | it("should call onChange handler with STOPPED timer state on setStopped", () => { 155 | const mockCallback = jest.fn(({ state }) => state); 156 | const state = new TimeModelState("INITED", mockCallback); 157 | 158 | state.setPlaying(); 159 | state.setStopped(); 160 | 161 | expect(mockCallback.mock.results[1].value).toEqual(STOPPED); 162 | }); 163 | 164 | it("should return true for isInited in INITED state", () => { 165 | const state = new TimeModelState(); 166 | 167 | expect(state.isInited()).toBeTruthy(); 168 | }); 169 | 170 | it("should return true for isPlaying in PLAYING state", () => { 171 | const state = new TimeModelState(); 172 | 173 | state.setPlaying(); 174 | 175 | expect(state.isPlaying()).toBeTruthy(); 176 | }); 177 | 178 | it("should return true for isPaused in PAUSED state", () => { 179 | const state = new TimeModelState(); 180 | 181 | state.setPlaying(); 182 | state.setPaused(); 183 | 184 | expect(state.isPaused()).toBeTruthy(); 185 | }); 186 | 187 | it("should return true for isStopped in STOPPED state", () => { 188 | const state = new TimeModelState(); 189 | 190 | state.setPlaying(); 191 | state.setStopped(); 192 | 193 | expect(state.isStopped()).toBeTruthy(); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/models/TimeModelState.ts: -------------------------------------------------------------------------------- 1 | import { TimeModelStateValues } from "../types"; 2 | 3 | export const INITED = "INITED"; 4 | export const PLAYING = "PLAYING"; 5 | export const PAUSED = "PAUSED"; 6 | export const STOPPED = "STOPPED"; 7 | 8 | export class TimeModelState { 9 | private onStateChange: (obj: { state: TimeModelStateValues }) => void; 10 | private state: TimeModelStateValues = INITED; 11 | 12 | constructor( 13 | initialStatus: TimeModelStateValues = INITED, 14 | onStateChange: (obj: { state: TimeModelStateValues }) => void = () => {} 15 | ) { 16 | this.state = initialStatus; 17 | this.onStateChange = onStateChange; 18 | } 19 | 20 | public setOnStateChange( 21 | onStateChange: (obj: { state: TimeModelStateValues }) => void 22 | ) { 23 | this.onStateChange = onStateChange; 24 | } 25 | 26 | public getState() { 27 | return this.state; 28 | } 29 | 30 | public setInited() { 31 | if (this.state === INITED) { 32 | return false; 33 | } 34 | 35 | this.state = INITED; 36 | 37 | this.onChange(); 38 | 39 | return true; 40 | } 41 | 42 | public isInited() { 43 | return this.state === INITED; 44 | } 45 | 46 | public setPlaying() { 47 | if (this.state === PLAYING) { 48 | return false; 49 | } 50 | 51 | this.state = PLAYING; 52 | 53 | this.onChange(); 54 | 55 | return true; 56 | } 57 | 58 | public isPlaying() { 59 | return this.state === PLAYING; 60 | } 61 | 62 | public setPaused() { 63 | if (this.state !== PLAYING) { 64 | return false; 65 | } 66 | 67 | this.state = PAUSED; 68 | 69 | this.onChange(); 70 | 71 | return true; 72 | } 73 | 74 | public isPaused() { 75 | return this.state === PAUSED; 76 | } 77 | 78 | public setStopped() { 79 | if (this.state === INITED) { 80 | return false; 81 | } 82 | 83 | this.state = STOPPED; 84 | 85 | this.onChange(); 86 | 87 | return true; 88 | } 89 | 90 | public isStopped() { 91 | return this.state === STOPPED; 92 | } 93 | 94 | private onChange() { 95 | this.onStateChange({ state: this.state }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/models/Units.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | ms: 'ms', 3 | s: 's', 4 | m: 'm', 5 | h: 'h', 6 | d: 'd', 7 | }; 8 | -------------------------------------------------------------------------------- /packages/react-compound-timer/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Unit = 'ms' | 's' | 'm' | 'h' | 'd'; 2 | 3 | export interface Checkpoint { 4 | time: number; 5 | callback: () => void; 6 | } 7 | 8 | export type Checkpoints = Checkpoint[]; 9 | 10 | export interface TimeParts { 11 | ms: number; 12 | s: number; 13 | m: number; 14 | h: number; 15 | d: number; 16 | } 17 | 18 | export type TimeModelStateValues = 'INITED' | 'PLAYING' | 'PAUSED' | 'STOPPED'; 19 | 20 | export type Direction = 'forward' | 'backward'; 21 | 22 | export type TimerValue = TimeParts & { state: TimeModelStateValues }; 23 | -------------------------------------------------------------------------------- /packages/react-compound-timer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "declaration": true, 5 | "module": "ES2015", 6 | "esModuleInterop": true, 7 | "moduleResolution": "Node", 8 | "allowSyntheticDefaultImports": true, 9 | "sourceMap": true, 10 | "jsx": "react-jsx", 11 | "baseUrl": ".", 12 | "lib": ["dom", "es2015", "es2016", "es2017"], 13 | "types": ["jest"], 14 | "outDir": "./build", 15 | "paths": { 16 | "src/*": ["src/*"] 17 | } 18 | }, 19 | "exclude": ["node_modules", "build", "src/**/*.test.tsx", "src/**/*.test.ts"], 20 | "include": ["src/**/*.ts", "src/**/*.tsx"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/react-compound-timer/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { defineConfig, Options } from "tsup"; 3 | 4 | const packageName = "react-compound-timer"; 5 | 6 | export default defineConfig((options) => { 7 | const commonOptions: Partial = { 8 | entry: { 9 | [packageName]: "src/index.ts", 10 | }, 11 | sourcemap: true, 12 | clean: true, 13 | ...options, 14 | }; 15 | 16 | return [ 17 | // standard ESM 18 | { 19 | ...commonOptions, 20 | format: ["esm"], 21 | dts: true, 22 | outExtension: () => ({ js: ".mjs" }), 23 | onSuccess() { 24 | // Support Webpack 4 by pointing `"module"` to a file with a `.js` extension 25 | fs.copyFileSync( 26 | `dist/${packageName}.mjs`, 27 | `dist/${packageName}.legacy-esm.js` 28 | ); 29 | 30 | return Promise.resolve(); 31 | }, 32 | }, 33 | // browser-ready ESM, production + minified 34 | { 35 | ...commonOptions, 36 | entry: { 37 | [`${packageName}.browser`]: "src/index.ts", 38 | }, 39 | format: ["esm"], 40 | outExtension: () => ({ js: ".mjs" }), 41 | minify: true, 42 | }, 43 | { 44 | ...commonOptions, 45 | format: "cjs", 46 | dts: true, 47 | outDir: "./dist/cjs/", 48 | outExtension: () => ({ js: ".cjs" }), 49 | }, 50 | ]; 51 | }); 52 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'demo/*' 3 | - 'packages/*' 4 | --------------------------------------------------------------------------------