├── .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 | [](https://www.npmjs.com/package/react-compound-timer) [](https://www.npmjs.com/package/react-compound-timer) [](https://www.npmjs.com/package/react-compound-timer) [](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 |
--------------------------------------------------------------------------------