├── .commitlintrc.json ├── .czrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── build_docker.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.json ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .stylelintignore ├── .stylelintrc.json ├── .versionrc.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Caddyfile ├── Dockerfile ├── LICENSE ├── README.md ├── assets └── banner.svg ├── astro.config.mjs ├── docker-compose.yml ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── assets │ └── pwa │ │ ├── 128.png │ │ ├── 144.png │ │ ├── 152.png │ │ ├── 192.png │ │ ├── 256.png │ │ ├── 512.png │ │ └── 72.png ├── favicon.svg ├── fonts │ ├── gloock-v6-latin-regular.woff2 │ ├── inter-v13-latin-500.woff2 │ ├── inter-v13-latin-regular.woff2 │ └── space-mono-v13-latin-700.woff2 ├── logo.svg ├── noise.png ├── og.png └── sounds │ └── alarm.mp3 ├── src ├── components │ ├── about.astro │ ├── app │ │ ├── app.module.css │ │ ├── app.tsx │ │ └── index.ts │ ├── container │ │ ├── container.module.css │ │ ├── container.tsx │ │ └── index.ts │ ├── footer.astro │ ├── form │ │ ├── field │ │ │ ├── field.module.css │ │ │ ├── field.tsx │ │ │ └── index.ts │ │ ├── form.module.css │ │ ├── form.tsx │ │ └── index.ts │ ├── hero.astro │ ├── modal │ │ ├── index.ts │ │ ├── modal.module.css │ │ └── modal.tsx │ ├── pomodoro │ │ ├── index.tsx │ │ └── pomodoro.module.css │ ├── portal │ │ ├── index.ts │ │ └── portal.tsx │ ├── reload │ │ ├── index.ts │ │ ├── reload-modal.tsx │ │ ├── reload.module.css │ │ └── reload.tsx │ ├── snackbar │ │ ├── index.ts │ │ ├── snackbar.module.css │ │ └── snackbar.tsx │ ├── store-consumer │ │ ├── index.ts │ │ └── store-consumer.tsx │ └── timers │ │ ├── index.ts │ │ ├── notice │ │ ├── index.ts │ │ ├── notice.module.css │ │ └── notice.tsx │ │ ├── timer │ │ ├── index.ts │ │ ├── timer.module.css │ │ ├── timer.tsx │ │ └── toolbar │ │ │ ├── index.ts │ │ │ ├── toolbar.module.css │ │ │ └── toolbar.tsx │ │ ├── timers.module.css │ │ └── timers.tsx ├── contexts │ └── snackbar.tsx ├── env.d.ts ├── helpers │ ├── number.ts │ └── styles.ts ├── hooks │ ├── use-alarm.ts │ ├── use-local-storage.ts │ ├── use-sound.ts │ └── use-ssr.ts ├── layouts │ └── layout.astro ├── pages │ └── index.astro ├── stores │ ├── alarm.ts │ ├── settings.ts │ └── timers.ts └── styles │ ├── base │ └── base.css │ ├── fonts.css │ ├── global.css │ └── variables │ ├── color.css │ ├── index.css │ └── typography.css └── tsconfig.json /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | 4 | "env": { 5 | "browser": true, 6 | "amd": true, 7 | "node": true, 8 | "es2022": true 9 | }, 10 | 11 | "parser": "@typescript-eslint/parser", 12 | 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module", 16 | "ecmaFeatures": { 17 | "jsx": true 18 | } 19 | }, 20 | 21 | "extends": [ 22 | "eslint:recommended", 23 | "plugin:@typescript-eslint/recommended", 24 | "plugin:typescript-sort-keys/recommended", 25 | "plugin:import/recommended", 26 | "plugin:react/recommended", 27 | "plugin:react/jsx-runtime", 28 | "plugin:jsx-a11y/recommended", 29 | "plugin:react-hooks/recommended", 30 | "plugin:astro/recommended", 31 | "prettier" 32 | ], 33 | 34 | "plugins": [ 35 | "@typescript-eslint", 36 | "typescript-sort-keys", 37 | "sort-keys-fix", 38 | "sort-destructure-keys", 39 | "prettier" 40 | ], 41 | 42 | "rules": { 43 | "prettier/prettier": "error", 44 | "sort-keys-fix/sort-keys-fix": ["warn", "asc"], 45 | "sort-destructure-keys/sort-destructure-keys": "warn", 46 | "jsx-a11y/no-static-element-interactions": "off", 47 | "jsx-a11y/no-noninteractive-tabindex": "off", 48 | "react/jsx-sort-props": [ 49 | "warn", 50 | { 51 | "callbacksLast": true, 52 | "multiline": "last" 53 | } 54 | ] 55 | }, 56 | 57 | "settings": { 58 | "react": { 59 | "version": "detect" 60 | }, 61 | 62 | "import/parsers": { 63 | "@typescript-eslint/parser": [".ts", ".tsx", ".js", ".jsx"] 64 | }, 65 | 66 | "import/resolver": { 67 | "typescript": true, 68 | "node": true, 69 | 70 | "alias": { 71 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".d.ts"], 72 | "map": [["@", "./src"]] 73 | } 74 | } 75 | }, 76 | 77 | "overrides": [ 78 | { 79 | "files": ["**/*.astro"], 80 | "parser": "astro-eslint-parser", 81 | 82 | "parserOptions": { 83 | "parser": "@typescript-eslint/parser", 84 | "extraFileExtensions": [".astro"] 85 | }, 86 | 87 | "rules": { 88 | "prettier/prettier": "error", 89 | "react/no-unknown-property": "off", 90 | "react/jsx-no-undef": "off" 91 | }, 92 | 93 | "globals": { 94 | "Astro": "readonly" 95 | } 96 | }, 97 | 98 | { 99 | "files": ["**/*.astro/*.js"], 100 | "rules": { 101 | "prettier/prettier": "off" 102 | } 103 | } 104 | ] 105 | } 106 | -------------------------------------------------------------------------------- /.github/workflows/build_docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and push main image 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | push-store-image: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: 'Checkout GitHub Action' 14 | uses: actions/checkout@main 15 | 16 | - name: 'Login to GitHub Container Registry' 17 | uses: docker/login-action@v1 18 | with: 19 | registry: ghcr.io 20 | username: ${{github.actor}} 21 | password: ${{secrets.ACCESS_TOKEN}} 22 | 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v1 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v1 28 | 29 | - name: 'Build and push Inventory Image' 30 | run: | 31 | IMAGE_NAME="ghcr.io/remvze/timesy" 32 | 33 | GIT_TAG=${{ github.ref }} 34 | GIT_TAG=${GIT_TAG#refs/tags/} 35 | 36 | docker buildx build \ 37 | --platform linux/amd64,linux/arm64 \ 38 | -t $IMAGE_NAME:latest \ 39 | -t $IMAGE_NAME:$GIT_TAG \ 40 | --push . 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx,js,jsx}": "eslint --fix", 3 | "*.{json,md}": "prettier --write", 4 | "*.css": "stylelint --fix", 5 | "*.astro": ["eslint --fix", "stylelint --fix"], 6 | "*.html": ["prettier --write", "stylelint --fix"] 7 | } 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-astro"], 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "endOfLine": "lf", 6 | "tabWidth": 2, 7 | "semi": true 8 | } 9 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-recess-order", 5 | "stylelint-config-html", 6 | "stylelint-prettier/recommended" 7 | ], 8 | 9 | "rules": { 10 | "import-notation": "string", 11 | "selector-class-pattern": null, 12 | "no-descending-specificity": null 13 | }, 14 | 15 | "overrides": [ 16 | { 17 | "files": ["*.astro"], 18 | "rules": { 19 | "prettier/prettier": null 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.versionrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "✨ Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "🐛 Bug Fixes" 10 | }, 11 | { 12 | "type": "chore", 13 | "hidden": false, 14 | "section": "🚚 Chores" 15 | }, 16 | { 17 | "type": "docs", 18 | "hidden": false, 19 | "section": "📝 Documentation" 20 | }, 21 | { 22 | "type": "style", 23 | "hidden": false, 24 | "section": "💄 Styling" 25 | }, 26 | { 27 | "type": "refactor", 28 | "hidden": false, 29 | "section": "♻️ Code Refactoring" 30 | }, 31 | { 32 | "type": "perf", 33 | "hidden": false, 34 | "section": "⚡️ Performance Improvements" 35 | }, 36 | { 37 | "type": "test", 38 | "hidden": false, 39 | "section": "✅ Testing" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "astro-build.astro-vscode", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint", 6 | "stylelint.vscode-stylelint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact", 8 | "astro" 9 | ], 10 | "stylelint.validate": ["css", "html", "astro"], 11 | "editor.defaultFormatter": "esbenp.prettier-vscode", 12 | "editor.formatOnSave": true, 13 | "editor.codeActionsOnSave": { 14 | "source.fixAll.eslint": "explicit", 15 | "source.fixAll.stylelint": "explicit" 16 | }, 17 | "[javascript][javascriptreact][typescript][typescriptreact][astro]": { 18 | "editor.formatOnSave": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.0.2](https://github.com/remvze/timesy/compare/v1.0.1...v1.0.2) (2025-02-19) 6 | 7 | 8 | ### 💄 Styling 9 | 10 | * enforce dark color scheme ([f47eb4d](https://github.com/remvze/timesy/commit/f47eb4d9b1902509598f1f533f380f424778931b)) 11 | 12 | 13 | ### ✨ Features 14 | 15 | * keep timer name on tab switch ([9c24806](https://github.com/remvze/timesy/commit/9c24806f45a1c334b1aa31ddb3cf6051ddb53ec1)) 16 | 17 | ### [1.0.1](https://github.com/remvze/timesy/compare/v1.0.0...v1.0.1) (2025-02-15) 18 | 19 | 20 | ### ✨ Features 21 | 22 | * add timer to the title ([ac49f3d](https://github.com/remvze/timesy/commit/ac49f3d762b66b656d87c8e8eeed87f8cce3f798)) 23 | 24 | ## [1.0.0](https://github.com/remvze/timesy/compare/v0.0.6...v1.0.0) (2025-02-15) 25 | 26 | 27 | ### 🚚 Chores 28 | 29 | * **release:** 0.1.0 ([d4eecd6](https://github.com/remvze/timesy/commit/d4eecd6338759b24811440bfe455043f6df52722)) 30 | * update domain ([e56b3e9](https://github.com/remvze/timesy/commit/e56b3e9fc828cb9c74f58706f84c66ba125c9601)) 31 | * update domain ([9318aeb](https://github.com/remvze/timesy/commit/9318aebebab06a779ff300349db4f25f37820ad9)) 32 | * update README file ([02b6d4c](https://github.com/remvze/timesy/commit/02b6d4c6a352b4870e906b45ff8f5a4b91d3e85b)) 33 | 34 | 35 | ### 💄 Styling 36 | 37 | * design the setting modal ([d6f3f56](https://github.com/remvze/timesy/commit/d6f3f5645ce086fabd4ace2d9b4714c28ea345cc)) 38 | * improve design ([3c4c187](https://github.com/remvze/timesy/commit/3c4c18727a54fcdaa278e1f46a24ce40ba6385bc)) 39 | * improve the hero ([0abc7cd](https://github.com/remvze/timesy/commit/0abc7cd7ed952e9d503b12f165b152a8d4a505d7)) 40 | * minor change ([0cebee8](https://github.com/remvze/timesy/commit/0cebee8754b92c99f982537fb2d2ebda53898aa2)) 41 | * replace toolbar ([e3a77a4](https://github.com/remvze/timesy/commit/e3a77a48203f1158418009f256d729c615f0ec97)) 42 | 43 | 44 | ### ✨ Features 45 | 46 | * add auto start option ([45ec740](https://github.com/remvze/timesy/commit/45ec740b48fc23b7f7577b836bc5197fb7662a67)) 47 | * add basic moving mechanism ([161b455](https://github.com/remvze/timesy/commit/161b45574324e4d91fd2689a588185503c0bab13)) 48 | * add basic pomodoro timer ([4ded07c](https://github.com/remvze/timesy/commit/4ded07c184918147aa922fe059ef791663d2761b)) 49 | * add basic pwa ([4d9de85](https://github.com/remvze/timesy/commit/4d9de851436ba24e4915926981afe9c5fae2bf4f)) 50 | * add disabled state to toolbar ([e362dac](https://github.com/remvze/timesy/commit/e362dac3f7090f387582bca30dd6cea98ea4e751)) 51 | * add shortcuts ([21e4388](https://github.com/remvze/timesy/commit/21e4388f3c56621f73e343a7909681b0d484f4da)) 52 | * add tabs ([8708aea](https://github.com/remvze/timesy/commit/8708aea51e5d2b52e33d53948fd390a06b45805b)) 53 | * add toolbar to timer ([c852e3b](https://github.com/remvze/timesy/commit/c852e3b3390b6070e48878ff22e2cda6dbaed9ae)) 54 | * add volume settings ([536d6b6](https://github.com/remvze/timesy/commit/536d6b671f6c5aa172cc1746297e5dd31446794c)) 55 | * change hero ([11e5a0b](https://github.com/remvze/timesy/commit/11e5a0b9490536d9b2d6408a6aa28a54ed58a4e6)) 56 | * change to reload prompt ([1c6726f](https://github.com/remvze/timesy/commit/1c6726f8349c416c36d7a783a806931648fc7f62)) 57 | * replace reverse timer ([07fbfbe](https://github.com/remvze/timesy/commit/07fbfbec9de5683ab4bfcb23da0c3988c54aa6d8)) 58 | 59 | 60 | ### 🐛 Bug Fixes 61 | 62 | * make the cancel button close the modal ([e7a1b71](https://github.com/remvze/timesy/commit/e7a1b7137f7bb7a7934dc78c46425ce20cf7061c)) 63 | * minor change ([cc8e9b7](https://github.com/remvze/timesy/commit/cc8e9b7c06d74530785acbea75f6d2ba5c313c09)) 64 | * remove pwa file ([e335527](https://github.com/remvze/timesy/commit/e3355279b419e22bf00af3ba98fc9fb5339e84e7)) 65 | * render reload modal only in the browser ([2b263e2](https://github.com/remvze/timesy/commit/2b263e22538d2181ff818648bc46fafcc6be41bf)) 66 | 67 | ## [0.1.0](https://github.com/remvze/timesy/compare/v0.0.6...v0.1.0) (2024-09-16) 68 | 69 | 70 | ### 🚚 Chores 71 | 72 | * update README file ([02b6d4c](https://github.com/remvze/timesy/commit/02b6d4c6a352b4870e906b45ff8f5a4b91d3e85b)) 73 | 74 | 75 | ### 💄 Styling 76 | 77 | * replace toolbar ([e3a77a4](https://github.com/remvze/timesy/commit/e3a77a48203f1158418009f256d729c615f0ec97)) 78 | 79 | 80 | ### 🐛 Bug Fixes 81 | 82 | * remove pwa file ([e335527](https://github.com/remvze/timesy/commit/e3355279b419e22bf00af3ba98fc9fb5339e84e7)) 83 | * render reload modal only in the browser ([2b263e2](https://github.com/remvze/timesy/commit/2b263e22538d2181ff818648bc46fafcc6be41bf)) 84 | 85 | 86 | ### ✨ Features 87 | 88 | * add auto start option ([45ec740](https://github.com/remvze/timesy/commit/45ec740b48fc23b7f7577b836bc5197fb7662a67)) 89 | * add basic moving mechanism ([161b455](https://github.com/remvze/timesy/commit/161b45574324e4d91fd2689a588185503c0bab13)) 90 | * add basic pwa ([4d9de85](https://github.com/remvze/timesy/commit/4d9de851436ba24e4915926981afe9c5fae2bf4f)) 91 | * add disabled state to toolbar ([e362dac](https://github.com/remvze/timesy/commit/e362dac3f7090f387582bca30dd6cea98ea4e751)) 92 | * add shortcuts ([21e4388](https://github.com/remvze/timesy/commit/21e4388f3c56621f73e343a7909681b0d484f4da)) 93 | * add toolbar to timer ([c852e3b](https://github.com/remvze/timesy/commit/c852e3b3390b6070e48878ff22e2cda6dbaed9ae)) 94 | * change to reload prompt ([1c6726f](https://github.com/remvze/timesy/commit/1c6726f8349c416c36d7a783a806931648fc7f62)) 95 | * replace reverse timer ([07fbfbe](https://github.com/remvze/timesy/commit/07fbfbec9de5683ab4bfcb23da0c3988c54aa6d8)) 96 | 97 | ### [0.0.6](https://github.com/remvze/timesy/compare/v0.0.5...v0.0.6) (2024-05-25) 98 | 99 | ### 0.0.5 (2024-05-25) 100 | 101 | 102 | ### 💄 Styling 103 | 104 | * add basic global styles ([f4313a2](https://github.com/remvze/timesy/commit/f4313a2360ec343985d656563d10538172a27674)) 105 | * add divider ([ea44b04](https://github.com/remvze/timesy/commit/ea44b04a20de7f83a6d72669a0bc6c2f03d79a63)) 106 | * add letter spacing ([9691de1](https://github.com/remvze/timesy/commit/9691de1980e92e133bf3bd33a3c9ba8ba5b4f969)) 107 | * change logo style ([989f4e4](https://github.com/remvze/timesy/commit/989f4e4c53a1683e07a0b84ee36aea7eac1f71da)) 108 | * decrease padding ([18be119](https://github.com/remvze/timesy/commit/18be119735ae2d688d0fe283793c55b830224e6e)) 109 | * increase height ([3e4c125](https://github.com/remvze/timesy/commit/3e4c12589399cbc676c68d0a10be5e6f9696f7f9)) 110 | * lowercase name ([93b3732](https://github.com/remvze/timesy/commit/93b3732987dcee755e341f56ed13deafd6fbc01c)) 111 | * update hero ([7144c76](https://github.com/remvze/timesy/commit/7144c76cebe4a2c30bffb48e4649ca5899b76532)) 112 | 113 | 114 | ### ♻️ Code Refactoring 115 | 116 | * lowercase file name ([aabb536](https://github.com/remvze/timesy/commit/aabb53640b46f3a9495a96f679fd8c4dfce4237d)) 117 | * make alarm hook ([ba2f7f5](https://github.com/remvze/timesy/commit/ba2f7f5a33ee0a59aed1890055cae3934707b121)) 118 | * migrate to Astro components ([fe2672d](https://github.com/remvze/timesy/commit/fe2672d024ad6cd973f27ec4ea42e637b1d12581)) 119 | * migrate to Astro components ([29cbb75](https://github.com/remvze/timesy/commit/29cbb7504afe95592400dec71b868add0253398a)) 120 | * relocate each component ([5b89e3b](https://github.com/remvze/timesy/commit/5b89e3b2244f08265f039c9d8c5dda6ce4dd6813)) 121 | 122 | 123 | ### 🐛 Bug Fixes 124 | 125 | * add color to primary button ([f19b37f](https://github.com/remvze/timesy/commit/f19b37fec5f25fc4c35885580a796b96bf430eba)) 126 | * correct timer for inactive tabs (wip) ([f00c5dd](https://github.com/remvze/timesy/commit/f00c5dd1b89780cea1cb6335c9a9a7ae35d79567)) 127 | * correct timer for inactive tabs (wip) ([ee85a6c](https://github.com/remvze/timesy/commit/ee85a6c74e346be10a26b8435544ebc3417d8add)) 128 | * remove confirmations ([8751ad9](https://github.com/remvze/timesy/commit/8751ad9d7059050de2ab76693785e4c94118e9b8)) 129 | * remove the title ([d4ab5b3](https://github.com/remvze/timesy/commit/d4ab5b35ad899447ccfb7005e9d215b968f53da8)) 130 | * turn off autocomplete for Firefox ([885443d](https://github.com/remvze/timesy/commit/885443de23e12e8417becb89ca60062bd65b83fb)) 131 | * typo ([5344e98](https://github.com/remvze/timesy/commit/5344e98266c319d9febac3d997448a5ac3196f8e)) 132 | 133 | 134 | ### 🚚 Chores 135 | 136 | * add Commitizen ([6265fb2](https://github.com/remvze/timesy/commit/6265fb2230d5c40841c6046d3496297cb215524d)) 137 | * add Commitlint ([278138e](https://github.com/remvze/timesy/commit/278138e2902d084044c364d72c7d0eb01de63630)) 138 | * add contributing guide ([b3a19af](https://github.com/remvze/timesy/commit/b3a19aff551fd352cf062f47f66612e1c33febf1)) 139 | * add Editor Config ([f53b132](https://github.com/remvze/timesy/commit/f53b13272c24ff0a43e51cdd811adefd250f7327)) 140 | * add ESLint ([531715d](https://github.com/remvze/timesy/commit/531715dc2af2bd9123d8835ebe4e5471a267ba5f)) 141 | * add features to README file ([3210879](https://github.com/remvze/timesy/commit/3210879f9833a744d60206315849eb826b8b9f6b)) 142 | * add Husky ([f5ebba6](https://github.com/remvze/timesy/commit/f5ebba6218edf4ea4ebaf429686e0f93bfb32e2e)) 143 | * add Lint Staged ([4de2b2d](https://github.com/remvze/timesy/commit/4de2b2d2d347281b9c6957c007ab37fe43945cad)) 144 | * add npm config ([ec9bf5a](https://github.com/remvze/timesy/commit/ec9bf5a022751d3c8d298f5580476efe2243c72a)) 145 | * add OG image ([166e613](https://github.com/remvze/timesy/commit/166e6134533c2e020cd0464d3c6a17eef95034d7)) 146 | * add path alias ([3b633df](https://github.com/remvze/timesy/commit/3b633df1c5b648d3e6ddd3e85e8ae8ef4f99a4e5)) 147 | * add PostCSS ([17e6b8b](https://github.com/remvze/timesy/commit/17e6b8b22132635a2d2f997ed22ac1bc3910c7c0)) 148 | * add Prettier ([12f2653](https://github.com/remvze/timesy/commit/12f2653cef93487a1237bdedc6b1e4f66e02c27d)) 149 | * add Standard Version ([7237ab4](https://github.com/remvze/timesy/commit/7237ab41389b0433ac52e00ae3ee63125d163078)) 150 | * add Stylelint ([0bce387](https://github.com/remvze/timesy/commit/0bce387b9b9f86088709762b83a743625e9bbe26)) 151 | * change favicon ([57e7470](https://github.com/remvze/timesy/commit/57e7470a15558198833c67edbdbed0e8c2a2827b)) 152 | * change image ([bf4913b](https://github.com/remvze/timesy/commit/bf4913bee18f49155abf5d1b093fe626689a811c)) 153 | * change unsupported emoji ([68168ad](https://github.com/remvze/timesy/commit/68168ad187fbc7a3a15bd00e9a5cc679d18aef05)) 154 | * fix error ([5a4ebb0](https://github.com/remvze/timesy/commit/5a4ebb0c55491f8063092d170dd214556c62236f)) 155 | * **release:** 0.0.1 ([f76ec96](https://github.com/remvze/timesy/commit/f76ec964146f3c8d614fca64230ec9162b76bf2c)) 156 | * **release:** 0.0.2 ([3339589](https://github.com/remvze/timesy/commit/3339589ae7e95162251046abc4a36be40a84d94a)) 157 | * **release:** 0.0.3 ([41036a6](https://github.com/remvze/timesy/commit/41036a61e47e04aa7b8a5403948e84597ebc3629)) 158 | * **release:** 0.0.4 ([7819b71](https://github.com/remvze/timesy/commit/7819b71ef70af3236f35756033133434547f8a4b)) 159 | * update README file ([5dc40d0](https://github.com/remvze/timesy/commit/5dc40d03521bd8620354b390dfd25beb1f7ce64f)) 160 | 161 | 162 | ### ✨ Features 163 | 164 | * add about section ([aa56d00](https://github.com/remvze/timesy/commit/aa56d00bee41fdb5febed40d4aa6b31ed00d2bc3)) 165 | * add alarm on end ([a2d8b61](https://github.com/remvze/timesy/commit/a2d8b617aaad2f7a3484f938087d077aa1650cad)) 166 | * add aria-disabled to disabled buttons ([ea4c269](https://github.com/remvze/timesy/commit/ea4c2695e31009ff022fa09b46f145ed14269db1)) 167 | * add auto animation ([a124fd6](https://github.com/remvze/timesy/commit/a124fd6b4f1eac3e03072857851b8a77695dbbfc)) 168 | * add basic structure ([3cd9b93](https://github.com/remvze/timesy/commit/3cd9b934d98c8d1879d3644300ac930c8941525b)) 169 | * add deleting functionality ([0f43b6c](https://github.com/remvze/timesy/commit/0f43b6c1a362fd31df0c594bbf7e576e8d088f2f)) 170 | * add Docker support ([2f815f3](https://github.com/remvze/timesy/commit/2f815f3bd53bb9558a375bab2631af78f1f55647)) 171 | * add finished state ([b1bf287](https://github.com/remvze/timesy/commit/b1bf2875edebcc5b794280b9e156e4382c0b7ea6)) 172 | * add fixed size to image ([160cbab](https://github.com/remvze/timesy/commit/160cbabff9f91ad15977eadee1800d479036b9fa)) 173 | * add footer component ([20764f0](https://github.com/remvze/timesy/commit/20764f06ad4acfd330c6805c9f230bcca1c89a02)) 174 | * add hero component ([d6e04e9](https://github.com/remvze/timesy/commit/d6e04e94058dd3511cfcddf78bfd726acab3d7f4)) 175 | * add logo to hero ([e06a217](https://github.com/remvze/timesy/commit/e06a21739f7f51d2c0de925b8495e776db5bed9d)) 176 | * add more meta tags ([74eb8cc](https://github.com/remvze/timesy/commit/74eb8cc6c78625b050f0357e5c368a29a769a20e)) 177 | * add notice ([86554f6](https://github.com/remvze/timesy/commit/86554f6f918b5b95e306113e1cc627a973ad691a)) 178 | * add reset functionality ([868bf14](https://github.com/remvze/timesy/commit/868bf1457f2833f795ec6046eb86525b0294c815)) 179 | * add reverse timer ([60bd100](https://github.com/remvze/timesy/commit/60bd100a0885c49d3a4e75df3bd20542b4ec2149)) 180 | * add support request ([256c5c9](https://github.com/remvze/timesy/commit/256c5c9ff0b9f7cd68aeed3eda1f04e684db360e)) 181 | * add total and spent minutes ([325af1c](https://github.com/remvze/timesy/commit/325af1ce0da2e0ad578d12806955077129c917d1)) 182 | * add total tracked time ([3b3db7a](https://github.com/remvze/timesy/commit/3b3db7a72bac1ef79a9bb374760c942ed884d1b5)) 183 | * change headings ([211ca08](https://github.com/remvze/timesy/commit/211ca0895f3cda9f505c95aa550244cbdb2c3517)) 184 | * disable play button on finished ([667308f](https://github.com/remvze/timesy/commit/667308f2359e5938a09b76c2707eb13ccb34f75f)) 185 | * implement basic timer ([d3c15a2](https://github.com/remvze/timesy/commit/d3c15a279fa9981e8d4ba7da94faff97b3cc8429)) 186 | * implement snackbar ([3cee118](https://github.com/remvze/timesy/commit/3cee118ee8a848811ecdfb1dd9ed93da4468037f)) 187 | * make timers persistant ([aaf2eab](https://github.com/remvze/timesy/commit/aaf2eab3e3e71dea78b19fad86a7949a7efd1d1b)) 188 | 189 | ### [0.0.4](https://github.com/remvze/timesy/compare/v0.0.3...v0.0.4) (2024-05-23) 190 | 191 | 192 | ### ♻️ Code Refactoring 193 | 194 | * migrate to Astro components ([fe2672d](https://github.com/remvze/timesy/commit/fe2672d024ad6cd973f27ec4ea42e637b1d12581)) 195 | * migrate to Astro components ([29cbb75](https://github.com/remvze/timesy/commit/29cbb7504afe95592400dec71b868add0253398a)) 196 | 197 | 198 | ### ✨ Features 199 | 200 | * add auto animation ([a124fd6](https://github.com/remvze/timesy/commit/a124fd6b4f1eac3e03072857851b8a77695dbbfc)) 201 | 202 | 203 | ### 🐛 Bug Fixes 204 | 205 | * correct timer for inactive tabs (wip) ([f00c5dd](https://github.com/remvze/timesy/commit/f00c5dd1b89780cea1cb6335c9a9a7ae35d79567)) 206 | * correct timer for inactive tabs (wip) ([ee85a6c](https://github.com/remvze/timesy/commit/ee85a6c74e346be10a26b8435544ebc3417d8add)) 207 | 208 | ### [0.0.3](https://github.com/remvze/timesy/compare/v0.0.2...v0.0.3) (2024-05-13) 209 | 210 | ### 🐛 Bug Fixes 211 | 212 | - add color to primary button ([f19b37f](https://github.com/remvze/timesy/commit/f19b37fec5f25fc4c35885580a796b96bf430eba)) 213 | - typo ([5344e98](https://github.com/remvze/timesy/commit/5344e98266c319d9febac3d997448a5ac3196f8e)) 214 | 215 | ### 🚚 Chores 216 | 217 | - add contributing guide ([b3a19af](https://github.com/remvze/timesy/commit/b3a19aff551fd352cf062f47f66612e1c33febf1)) 218 | - add features to README file ([3210879](https://github.com/remvze/timesy/commit/3210879f9833a744d60206315849eb826b8b9f6b)) 219 | - change image ([bf4913b](https://github.com/remvze/timesy/commit/bf4913bee18f49155abf5d1b093fe626689a811c)) 220 | - change unsupported emoji ([68168ad](https://github.com/remvze/timesy/commit/68168ad187fbc7a3a15bd00e9a5cc679d18aef05)) 221 | 222 | ### ✨ Features 223 | 224 | - add aria-disabled to disabled buttons ([ea4c269](https://github.com/remvze/timesy/commit/ea4c2695e31009ff022fa09b46f145ed14269db1)) 225 | - add notice ([86554f6](https://github.com/remvze/timesy/commit/86554f6f918b5b95e306113e1cc627a973ad691a)) 226 | - change headings ([211ca08](https://github.com/remvze/timesy/commit/211ca0895f3cda9f505c95aa550244cbdb2c3517)) 227 | - implement snackbar ([3cee118](https://github.com/remvze/timesy/commit/3cee118ee8a848811ecdfb1dd9ed93da4468037f)) 228 | 229 | ### [0.0.2](https://github.com/remvze/timesy/compare/v0.0.1...v0.0.2) (2024-05-08) 230 | 231 | ### 0.0.1 (2024-05-08) 232 | 233 | ### 💄 Styling 234 | 235 | - add basic global styles ([f4313a2](https://github.com/remvze/timesy/commit/f4313a2360ec343985d656563d10538172a27674)) 236 | - add divider ([ea44b04](https://github.com/remvze/timesy/commit/ea44b04a20de7f83a6d72669a0bc6c2f03d79a63)) 237 | - add letter spacing ([9691de1](https://github.com/remvze/timesy/commit/9691de1980e92e133bf3bd33a3c9ba8ba5b4f969)) 238 | - change logo style ([989f4e4](https://github.com/remvze/timesy/commit/989f4e4c53a1683e07a0b84ee36aea7eac1f71da)) 239 | - decrease padding ([18be119](https://github.com/remvze/timesy/commit/18be119735ae2d688d0fe283793c55b830224e6e)) 240 | - increase height ([3e4c125](https://github.com/remvze/timesy/commit/3e4c12589399cbc676c68d0a10be5e6f9696f7f9)) 241 | - lowercase name ([93b3732](https://github.com/remvze/timesy/commit/93b3732987dcee755e341f56ed13deafd6fbc01c)) 242 | - update hero ([7144c76](https://github.com/remvze/timesy/commit/7144c76cebe4a2c30bffb48e4649ca5899b76532)) 243 | 244 | ### ♻️ Code Refactoring 245 | 246 | - lowercase file name ([aabb536](https://github.com/remvze/timesy/commit/aabb53640b46f3a9495a96f679fd8c4dfce4237d)) 247 | - make alarm hook ([ba2f7f5](https://github.com/remvze/timesy/commit/ba2f7f5a33ee0a59aed1890055cae3934707b121)) 248 | - relocate each component ([5b89e3b](https://github.com/remvze/timesy/commit/5b89e3b2244f08265f039c9d8c5dda6ce4dd6813)) 249 | 250 | ### 🐛 Bug Fixes 251 | 252 | - remove confirmations ([8751ad9](https://github.com/remvze/timesy/commit/8751ad9d7059050de2ab76693785e4c94118e9b8)) 253 | - remove the title ([d4ab5b3](https://github.com/remvze/timesy/commit/d4ab5b35ad899447ccfb7005e9d215b968f53da8)) 254 | - turn off autocomplete for Firefox ([885443d](https://github.com/remvze/timesy/commit/885443de23e12e8417becb89ca60062bd65b83fb)) 255 | 256 | ### 🚚 Chores 257 | 258 | - add Commitizen ([6265fb2](https://github.com/remvze/timesy/commit/6265fb2230d5c40841c6046d3496297cb215524d)) 259 | - add Commitlint ([278138e](https://github.com/remvze/timesy/commit/278138e2902d084044c364d72c7d0eb01de63630)) 260 | - add Editor Config ([f53b132](https://github.com/remvze/timesy/commit/f53b13272c24ff0a43e51cdd811adefd250f7327)) 261 | - add ESLint ([531715d](https://github.com/remvze/timesy/commit/531715dc2af2bd9123d8835ebe4e5471a267ba5f)) 262 | - add Husky ([f5ebba6](https://github.com/remvze/timesy/commit/f5ebba6218edf4ea4ebaf429686e0f93bfb32e2e)) 263 | - add Lint Staged ([4de2b2d](https://github.com/remvze/timesy/commit/4de2b2d2d347281b9c6957c007ab37fe43945cad)) 264 | - add npm config ([ec9bf5a](https://github.com/remvze/timesy/commit/ec9bf5a022751d3c8d298f5580476efe2243c72a)) 265 | - add OG image ([166e613](https://github.com/remvze/timesy/commit/166e6134533c2e020cd0464d3c6a17eef95034d7)) 266 | - add path alias ([3b633df](https://github.com/remvze/timesy/commit/3b633df1c5b648d3e6ddd3e85e8ae8ef4f99a4e5)) 267 | - add PostCSS ([17e6b8b](https://github.com/remvze/timesy/commit/17e6b8b22132635a2d2f997ed22ac1bc3910c7c0)) 268 | - add Prettier ([12f2653](https://github.com/remvze/timesy/commit/12f2653cef93487a1237bdedc6b1e4f66e02c27d)) 269 | - add Standard Version ([7237ab4](https://github.com/remvze/timesy/commit/7237ab41389b0433ac52e00ae3ee63125d163078)) 270 | - add Stylelint ([0bce387](https://github.com/remvze/timesy/commit/0bce387b9b9f86088709762b83a743625e9bbe26)) 271 | - change favicon ([57e7470](https://github.com/remvze/timesy/commit/57e7470a15558198833c67edbdbed0e8c2a2827b)) 272 | - fix error ([5a4ebb0](https://github.com/remvze/timesy/commit/5a4ebb0c55491f8063092d170dd214556c62236f)) 273 | - update README file ([5dc40d0](https://github.com/remvze/timesy/commit/5dc40d03521bd8620354b390dfd25beb1f7ce64f)) 274 | 275 | ### ✨ Features 276 | 277 | - add about section ([aa56d00](https://github.com/remvze/timesy/commit/aa56d00bee41fdb5febed40d4aa6b31ed00d2bc3)) 278 | - add alarm on end ([a2d8b61](https://github.com/remvze/timesy/commit/a2d8b617aaad2f7a3484f938087d077aa1650cad)) 279 | - add basic structure ([3cd9b93](https://github.com/remvze/timesy/commit/3cd9b934d98c8d1879d3644300ac930c8941525b)) 280 | - add deleting functionality ([0f43b6c](https://github.com/remvze/timesy/commit/0f43b6c1a362fd31df0c594bbf7e576e8d088f2f)) 281 | - add Docker support ([2f815f3](https://github.com/remvze/timesy/commit/2f815f3bd53bb9558a375bab2631af78f1f55647)) 282 | - add finished state ([b1bf287](https://github.com/remvze/timesy/commit/b1bf2875edebcc5b794280b9e156e4382c0b7ea6)) 283 | - add fixed size to image ([160cbab](https://github.com/remvze/timesy/commit/160cbabff9f91ad15977eadee1800d479036b9fa)) 284 | - add footer component ([20764f0](https://github.com/remvze/timesy/commit/20764f06ad4acfd330c6805c9f230bcca1c89a02)) 285 | - add hero component ([d6e04e9](https://github.com/remvze/timesy/commit/d6e04e94058dd3511cfcddf78bfd726acab3d7f4)) 286 | - add logo to hero ([e06a217](https://github.com/remvze/timesy/commit/e06a21739f7f51d2c0de925b8495e776db5bed9d)) 287 | - add more meta tags ([74eb8cc](https://github.com/remvze/timesy/commit/74eb8cc6c78625b050f0357e5c368a29a769a20e)) 288 | - add reset functionality ([868bf14](https://github.com/remvze/timesy/commit/868bf1457f2833f795ec6046eb86525b0294c815)) 289 | - add reverse timer ([60bd100](https://github.com/remvze/timesy/commit/60bd100a0885c49d3a4e75df3bd20542b4ec2149)) 290 | - add total and spent minutes ([325af1c](https://github.com/remvze/timesy/commit/325af1ce0da2e0ad578d12806955077129c917d1)) 291 | - add total tracked time ([3b3db7a](https://github.com/remvze/timesy/commit/3b3db7a72bac1ef79a9bb374760c942ed884d1b5)) 292 | - disable play button on finished ([667308f](https://github.com/remvze/timesy/commit/667308f2359e5938a09b76c2707eb13ccb34f75f)) 293 | - implement basic timer ([d3c15a2](https://github.com/remvze/timesy/commit/d3c15a279fa9981e8d4ba7da94faff97b3cc8429)) 294 | - make timers persistant ([aaf2eab](https://github.com/remvze/timesy/commit/aaf2eab3e3e71dea78b19fad86a7949a7efd1d1b)) 295 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for considering contributing to our project! We welcome your contributions. 4 | 5 | ## How to Contribute 6 | 7 | 1. Fork the repository. 8 | 2. Create a new branch: `git checkout -b feature/your-feature-name`. 9 | 3. Make your changes and commit them: `git commit -m 'feat: add some feature'`. 10 | 4. Push to the branch: `git push origin feature/your-feature-name`. 11 | 5. Submit a pull request. ⚡ 12 | 13 | ⚠️ **Notice**: Commit messages should follow [Conventional Commits Specification](https://www.conventionalcommits.org/en/v1.0.0/). 14 | 15 | ## Report Bugs 16 | 17 | To report a bug, please open an issue on GitHub and provide detailed information about the bug, including steps to reproduce it. 18 | 19 | ## Request Features 20 | 21 | To request a new feature, open an issue on GitHub and describe the feature you would like to see added. 22 | 23 | ## License 24 | 25 | By contributing, you agree that your contributions will be licensed under the project's [LICENSE](LICENSE). 26 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | :8080 { 2 | file_server 3 | root * /var/www/html 4 | 5 | handle_errors { 6 | rewrite * /index.html 7 | file_server 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/node:20-alpine3.18 AS build 2 | WORKDIR /app 3 | COPY package*.json ./ 4 | RUN npm install 5 | COPY . . 6 | RUN npm run build 7 | 8 | FROM docker.io/caddy:latest 9 | COPY ./Caddyfile /etc/caddy/Caddyfile 10 | COPY --from=build /app/dist /var/www/html 11 | 12 | EXPOSE 8080 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 MAZE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Timesy Logo Banner 3 |

Timesy ⏳️

4 |

A distraction-free online timer.

5 | Visit Timesy | Buy Me a Coffee 6 |
7 | 8 | ## Features 9 | 10 | 1. ⌛ Unlimited number of timers. 11 | 1. ⚡ Run multiple timers simultaneously. 12 | 1. 📝 Name timers for better management. 13 | 1. 🛸 Minimal and distraction-free. 14 | 1. ⭐ Privacy friendly; no data collection. 15 | 1. 🪐 Free, open-source, and self-hostable. 16 | -------------------------------------------------------------------------------- /assets/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | 3 | import react from '@astrojs/react'; 4 | import AstroPWA from '@vite-pwa/astro'; 5 | 6 | // https://astro.build/config 7 | export default defineConfig({ 8 | integrations: [ 9 | react(), 10 | AstroPWA({ 11 | manifest: { 12 | background_color: '#09090b', 13 | description: 'Distraction-free online timer', 14 | display: 'standalone', 15 | icons: [ 16 | ...[72, 128, 144, 152, 192, 256, 512].map(size => ({ 17 | sizes: `${size}x${size}`, 18 | src: `/assets/pwa/${size}.png`, 19 | type: 'image/png', 20 | })), 21 | ], 22 | name: 'Timesy', 23 | orientation: 'any', 24 | scope: '/', 25 | short_name: 'Timesy', 26 | start_url: '/', 27 | theme_color: '#09090b', 28 | }, 29 | registerType: 'prompt', 30 | workbox: { 31 | globPatterns: ['**/*'], 32 | navigateFallback: '/', 33 | }, 34 | }), 35 | ], 36 | }); 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | timesy: 3 | image: ghcr.io/remvze/timesy 4 | logging: 5 | options: 6 | max-size: 1g 7 | restart: always 8 | ports: 9 | - '8080:8080' 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timesy", 3 | "type": "module", 4 | "version": "1.0.2", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro check && astro build", 9 | "preview": "astro preview", 10 | "astro": "astro", 11 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro", 12 | "lint:fix": "npm run lint -- --fix", 13 | "lint:style": "stylelint ./**/*.{css,astro,html}", 14 | "lint:style:fix": "npm run lint:style -- --fix", 15 | "format": "prettier . --write", 16 | "prepare": "husky", 17 | "commit": "git-cz", 18 | "release": "standard-version --no-verify", 19 | "release:major": "npm run release -- --release-as major", 20 | "release:minor": "npm run release -- --release-as minor", 21 | "release:patch": "npm run release -- --release-as patch" 22 | }, 23 | "dependencies": { 24 | "@astrojs/check": "^0.5.10", 25 | "@astrojs/react": "^3.3.1", 26 | "@formkit/auto-animate": "0.8.2", 27 | "@types/react": "^18.3.1", 28 | "@types/react-dom": "^18.3.0", 29 | "astro": "^4.7.0", 30 | "focus-trap-react": "10.2.3", 31 | "framer-motion": "11.1.9", 32 | "howler": "2.2.4", 33 | "react": "^18.3.1", 34 | "react-dom": "^18.3.1", 35 | "react-hotkeys-hook": "4.5.1", 36 | "react-icons": "5.1.0", 37 | "typescript": "^5.4.5", 38 | "uuid": "9.0.1", 39 | "uvcanvas": "0.2.1", 40 | "zustand": "4.5.2" 41 | }, 42 | "devDependencies": { 43 | "@commitlint/cli": "19.3.0", 44 | "@commitlint/config-conventional": "19.2.2", 45 | "@types/howler": "2.2.11", 46 | "@types/uuid": "9.0.8", 47 | "@typescript-eslint/eslint-plugin": "7.7.1", 48 | "@typescript-eslint/parser": "7.7.1", 49 | "@vite-pwa/astro": "0.4.0", 50 | "astro-eslint-parser": "1.0.1", 51 | "autoprefixer": "10.4.19", 52 | "commitizen": "4.3.0", 53 | "cz-conventional-changelog": "3.3.0", 54 | "eslint": "8.57.0", 55 | "eslint-config-prettier": "9.1.0", 56 | "eslint-import-resolver-alias": "1.1.2", 57 | "eslint-import-resolver-typescript": "3.6.1", 58 | "eslint-plugin-astro": "1.1.0", 59 | "eslint-plugin-import": "2.29.1", 60 | "eslint-plugin-jsx-a11y": "6.8.0", 61 | "eslint-plugin-prettier": "5.1.3", 62 | "eslint-plugin-react": "7.34.1", 63 | "eslint-plugin-react-hooks": "4.6.2", 64 | "eslint-plugin-sort-destructure-keys": "2.0.0", 65 | "eslint-plugin-sort-keys-fix": "1.1.2", 66 | "eslint-plugin-typescript-sort-keys": "3.2.0", 67 | "husky": "9.0.11", 68 | "lint-staged": "15.2.2", 69 | "postcss-html": "1.6.0", 70 | "postcss-nesting": "12.1.2", 71 | "prettier": "3.2.5", 72 | "prettier-plugin-astro": "0.13.0", 73 | "standard-version": "9.5.0", 74 | "stylelint": "16.4.0", 75 | "stylelint-config-html": "1.1.0", 76 | "stylelint-config-recess-order": "5.0.1", 77 | "stylelint-config-standard": "36.0.0", 78 | "stylelint-prettier": "5.0.0", 79 | "vite-plugin-pwa": "0.20.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | 'postcss-nesting': {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/assets/pwa/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/128.png -------------------------------------------------------------------------------- /public/assets/pwa/144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/144.png -------------------------------------------------------------------------------- /public/assets/pwa/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/152.png -------------------------------------------------------------------------------- /public/assets/pwa/192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/192.png -------------------------------------------------------------------------------- /public/assets/pwa/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/256.png -------------------------------------------------------------------------------- /public/assets/pwa/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/512.png -------------------------------------------------------------------------------- /public/assets/pwa/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/assets/pwa/72.png -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/fonts/gloock-v6-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/fonts/gloock-v6-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/inter-v13-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/fonts/inter-v13-latin-500.woff2 -------------------------------------------------------------------------------- /public/fonts/inter-v13-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/fonts/inter-v13-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/space-mono-v13-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/fonts/space-mono-v13-latin-700.woff2 -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /public/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/noise.png -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/og.png -------------------------------------------------------------------------------- /public/sounds/alarm.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/timesy/8f0218e04d244f9797c017c3f6271dbf480e1469/public/sounds/alarm.mp3 -------------------------------------------------------------------------------- /src/components/about.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Container } from './container'; 3 | --- 4 | 5 |
6 | 7 |

What is Timesy?

8 |

9 | Timesy is a free, open-source online timer designed to maximize focus and 10 | eliminate distractions. Run multiple timers simultaneously and store them 11 | locally for persistence across sessions. Whether you're a student, 12 | professional, or just someone who needs to stay on track, Timesy can help 13 | you achieve your goals. 14 |

15 |

16 | Enjoy Timesy? 17 | 22 | Support with a donation! 23 | 24 |

25 |
26 |
27 | 28 | 59 | -------------------------------------------------------------------------------- /src/components/app/app.module.css: -------------------------------------------------------------------------------- 1 | .tabs { 2 | display: flex; 3 | column-gap: 4px; 4 | align-items: center; 5 | margin-bottom: 32px; 6 | 7 | & button { 8 | flex-grow: 1; 9 | height: 44px; 10 | font-size: var(--font-sm); 11 | color: var(--color-foreground-subtle); 12 | cursor: pointer; 13 | background: transparent; 14 | border: none; 15 | border-radius: 8px; 16 | 17 | &.active { 18 | font-weight: 500; 19 | color: var(--color-foreground); 20 | } 21 | 22 | &.countdown.active { 23 | background: linear-gradient( 24 | 90deg, 25 | var(--color-neutral-100), 26 | var(--color-neutral-50) 27 | ); 28 | box-shadow: inset 3px 0 0 var(--color-neutral-300); 29 | } 30 | 31 | &.pomodoro.active { 32 | background: linear-gradient( 33 | 90deg, 34 | var(--color-neutral-50), 35 | var(--color-neutral-100) 36 | ); 37 | box-shadow: inset -3px 0 0 var(--color-neutral-300); 38 | } 39 | } 40 | 41 | & .divider { 42 | width: 1px; 43 | height: 32px; 44 | background-color: var(--color-neutral-200); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/app/app.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { Container } from '@/components/container'; 4 | import { Form } from '@/components/form'; 5 | import { Timers } from '@/components/timers'; 6 | import { StoreConsumer } from '@/components/store-consumer'; 7 | import { SnackbarProvider } from '@/contexts/snackbar'; 8 | import { PomodoroTimer } from '../pomodoro'; 9 | 10 | import styles from './app.module.css'; 11 | import { cn } from '@/helpers/styles'; 12 | 13 | export function App() { 14 | const [tab, setTab] = useState('countdown'); 15 | const [timerName, setTimerName] = useState(''); 16 | 17 | return ( 18 | 19 | 20 | 21 |
22 | 31 |
32 | 41 |
42 | 43 | {tab === 'countdown' && ( 44 | <> 45 |
46 | 47 | 48 | )} 49 | 50 | {tab === 'pomodoro' && } 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/app/index.ts: -------------------------------------------------------------------------------- 1 | export { App } from './app'; 2 | -------------------------------------------------------------------------------- /src/components/container/container.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 90%; 3 | max-width: 420px; 4 | margin: 0 auto; 5 | 6 | &.fullWidth { 7 | width: 100%; 8 | max-width: 500px; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/container/container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/helpers/styles'; 2 | import styles from './container.module.css'; 3 | 4 | interface ContainerProps { 5 | children: React.ReactNode; 6 | fullWidth?: boolean; 7 | } 8 | 9 | export function Container({ children, fullWidth }: ContainerProps) { 10 | return ( 11 |
12 | {children} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/container/index.ts: -------------------------------------------------------------------------------- 1 | export { Container } from './container'; 2 | -------------------------------------------------------------------------------- /src/components/footer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Container } from './container'; 3 | --- 4 | 5 | 6 |
7 |

8 | Source code on GitHub 9 |

10 | 11 |

12 | Created by Maze ✦ 13 |

14 |
15 |
16 | 17 | 39 | -------------------------------------------------------------------------------- /src/components/form/field/field.module.css: -------------------------------------------------------------------------------- 1 | .field { 2 | flex-grow: 1; 3 | 4 | & .label { 5 | display: block; 6 | margin-bottom: 8px; 7 | font-size: var(--font-sm); 8 | font-weight: 500; 9 | 10 | & .optional { 11 | font-weight: 400; 12 | color: var(--color-foreground-subtle); 13 | } 14 | } 15 | 16 | & .input { 17 | width: 100%; 18 | min-width: 0; 19 | height: 40px; 20 | padding: 0 16px; 21 | color: var(--color-foreground); 22 | background-color: var(--color-neutral-100); 23 | border: 1px solid var(--color-neutral-200); 24 | border-radius: 8px; 25 | outline: none; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/form/field/field.tsx: -------------------------------------------------------------------------------- 1 | import styles from './field.module.css'; 2 | 3 | interface FieldProps { 4 | children?: React.ReactNode; 5 | label: string; 6 | onChange: (value: string | number) => void; 7 | optional?: boolean; 8 | type: 'text' | 'select'; 9 | value: string | number; 10 | } 11 | 12 | export function Field({ 13 | children, 14 | label, 15 | onChange, 16 | optional, 17 | type, 18 | value, 19 | }: FieldProps) { 20 | return ( 21 |
22 | 26 | 27 | {type === 'text' && ( 28 | onChange(e.target.value)} 35 | /> 36 | )} 37 | 38 | {type === 'select' && ( 39 | 48 | )} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/form/field/index.ts: -------------------------------------------------------------------------------- 1 | export { Field } from './field'; 2 | -------------------------------------------------------------------------------- /src/components/form/form.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | flex-direction: column; 4 | row-gap: 24px; 5 | 6 | & .buttons { 7 | display: flex; 8 | column-gap: 8px; 9 | align-items: center; 10 | 11 | & button { 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | width: 45px; 16 | height: 45px; 17 | font-weight: 500; 18 | color: var(--color-foreground-subtle); 19 | cursor: pointer; 20 | background-color: var(--color-neutral-200); 21 | border: none; 22 | border-radius: 8px; 23 | outline: none; 24 | box-shadow: inset 0 -3px 0 var(--color-neutral-100); 25 | transition: 0.2s; 26 | 27 | &:not(.primary):hover { 28 | color: var(--color-foreground); 29 | } 30 | 31 | &.primary { 32 | flex-grow: 1; 33 | color: var(--color-neutral-50); 34 | background-color: var(--color-neutral-950); 35 | box-shadow: inset 0 -3px 0 var(--color-neutral-700); 36 | } 37 | } 38 | } 39 | } 40 | 41 | .timeFields { 42 | display: flex; 43 | column-gap: 12px; 44 | align-items: flex-end; 45 | justify-content: space-between; 46 | } 47 | 48 | .autoStart { 49 | user-select: none; 50 | color: var(--color-foreground-subtle); 51 | 52 | & label { 53 | display: flex; 54 | align-items: center; 55 | max-width: max-content; 56 | } 57 | 58 | & input { 59 | width: 14px; 60 | height: 14px; 61 | margin-right: 8px; 62 | accent-color: var(--color-foreground-subtler); 63 | } 64 | } 65 | 66 | .settings { 67 | & h2 { 68 | margin-bottom: 20px; 69 | font-family: var(--font-display); 70 | font-size: var(--font-md); 71 | } 72 | 73 | & .field { 74 | & label { 75 | display: block; 76 | margin-bottom: 4px; 77 | font-size: var(--font-sm); 78 | font-weight: 500; 79 | color: var(--color-foreground); 80 | } 81 | 82 | & input { 83 | display: block; 84 | width: 100%; 85 | min-width: 0; 86 | accent-color: var(--color-foreground-subtler); 87 | } 88 | } 89 | 90 | & .notice { 91 | padding: 12px; 92 | margin-top: 20px; 93 | font-size: var(--font-sm); 94 | line-height: 1.6; 95 | color: var(--color-foreground-subtle); 96 | border: 1px dashed var(--color-neutral-200); 97 | border-radius: 4px; 98 | 99 | & strong { 100 | font-weight: 500; 101 | color: var(--color-foreground); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/components/form/form.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from 'react'; 2 | import { IoMdSettings } from 'react-icons/io'; 3 | 4 | import { Modal } from '../modal'; 5 | import { Field } from './field'; 6 | 7 | import { useTimers } from '@/stores/timers'; 8 | 9 | import styles from './form.module.css'; 10 | import { useSettings } from '@/stores/settings'; 11 | 12 | interface FormProps { 13 | onTimerNameChange: (name: string) => void; 14 | timerName: string; 15 | } 16 | 17 | export function Form({ onTimerNameChange, timerName }: FormProps) { 18 | const [showSettings, setShowSettings] = useState(false); 19 | 20 | const volume = useSettings(state => state.volume); 21 | const setVolume = useSettings(state => state.setVolume); 22 | 23 | const [hours, setHours] = useState(0); 24 | const [minutes, setMinutes] = useState(10); 25 | const [seconds, setSeconds] = useState(0); 26 | const [autoStart, setAutoStart] = useState(false); 27 | 28 | const totalSeconds = useMemo( 29 | () => hours * 60 * 60 + minutes * 60 + seconds, 30 | [hours, minutes, seconds], 31 | ); 32 | 33 | const add = useTimers(state => state.add); 34 | 35 | const handleSubmit = (e: React.FormEvent) => { 36 | e.preventDefault(); 37 | 38 | if (totalSeconds === 0) return; 39 | 40 | add({ 41 | autoStart, 42 | name: timerName, 43 | total: totalSeconds, 44 | }); 45 | 46 | onTimerNameChange(''); 47 | }; 48 | 49 | return ( 50 | <> 51 | 52 | onTimerNameChange(value as string)} 58 | /> 59 | 60 |
61 | setHours(value as number)} 66 | > 67 | {Array(13) 68 | .fill(null) 69 | .map((_, index) => ( 70 | 73 | ))} 74 | 75 | 76 | setMinutes(value as number)} 81 | > 82 | {Array(60) 83 | .fill(null) 84 | .map((_, index) => ( 85 | 88 | ))} 89 | 90 | 91 | setSeconds(value as number)} 96 | > 97 | {Array(60) 98 | .fill(null) 99 | .map((_, index) => ( 100 | 103 | ))} 104 | 105 |
106 | 107 |
108 | 116 |
117 | 118 |
119 | 122 | 125 |
126 | 127 | 128 | setShowSettings(false)}> 129 |
130 |

Settings

131 | 132 |
133 | 134 | setVolume(+e.target.value)} 141 | /> 142 |
143 | 144 |
145 | Notice: Changes to these settings will affect all 146 | timers and are saved automatically. 147 |
148 |
149 |
150 | 151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /src/components/form/index.ts: -------------------------------------------------------------------------------- 1 | export { Form } from './form'; 2 | -------------------------------------------------------------------------------- /src/components/hero.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Velustro } from 'uvcanvas'; 3 | 4 | import { Container } from './container'; 5 | --- 6 | 7 | 8 |
9 |
10 | 11 |
12 |
13 |
14 | 15 | 18 | 19 |

Timesy

20 |

A distraction-free online timer.

21 |
22 |
23 | 24 | 130 | -------------------------------------------------------------------------------- /src/components/modal/index.ts: -------------------------------------------------------------------------------- 1 | export { Modal } from './modal'; 2 | -------------------------------------------------------------------------------- /src/components/modal/modal.module.css: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: fixed; 3 | inset: 0; 4 | z-index: 20; 5 | background-color: rgb(9 9 11 / 40%); 6 | backdrop-filter: blur(5px); 7 | } 8 | 9 | .modal { 10 | position: fixed; 11 | top: 50%; 12 | left: 50%; 13 | z-index: 20; 14 | width: 100%; 15 | max-height: 100%; 16 | padding: 50px 0; 17 | overflow-y: auto; 18 | pointer-events: none; 19 | transform: translate(-50%, -50%); 20 | 21 | & .content { 22 | position: relative; 23 | width: 90%; 24 | max-width: 500px; 25 | padding: 20px; 26 | padding-top: 40px; 27 | margin: 0 auto; 28 | pointer-events: fill; 29 | background-color: var(--color-neutral-100); 30 | border-radius: 8px; 31 | 32 | & .close { 33 | position: absolute; 34 | top: 10px; 35 | right: 10px; 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | width: 20px; 40 | height: 20px; 41 | font-size: 16px; 42 | color: var(--color-foreground-subtle); 43 | cursor: pointer; 44 | background-color: transparent; 45 | border: none; 46 | border-radius: 4px; 47 | outline: none; 48 | 49 | &:focus-visible { 50 | outline: 2px solid var(--color-neutral-400); 51 | outline-offset: 2px; 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/modal/modal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { AnimatePresence, motion } from 'framer-motion'; 3 | import { IoClose } from 'react-icons/io5'; 4 | import FocusTrap from 'focus-trap-react'; 5 | 6 | import { Portal } from '@/components/portal'; 7 | 8 | import styles from './modal.module.css'; 9 | 10 | interface ModalProps { 11 | children: React.ReactNode; 12 | lockBody?: boolean; 13 | onClose: () => void; 14 | show: boolean; 15 | } 16 | 17 | export function Modal({ 18 | children, 19 | lockBody = true, 20 | onClose, 21 | show, 22 | }: ModalProps) { 23 | const variants = { 24 | modal: { 25 | hidden: { 26 | opacity: 0, 27 | y: 20, 28 | }, 29 | show: { 30 | opacity: 1, 31 | y: 0, 32 | }, 33 | }, 34 | overlay: { 35 | hidden: { opacity: 1 }, 36 | show: { opacity: 1 }, 37 | }, 38 | }; 39 | 40 | useEffect(() => { 41 | if (show && lockBody) { 42 | document.body.style.overflow = 'hidden'; 43 | } else if (lockBody) { 44 | document.body.style.overflow = 'auto'; 45 | } 46 | }, [show, lockBody]); 47 | 48 | useEffect(() => { 49 | function keyListener(e: KeyboardEvent) { 50 | if (show && e.key === 'Escape') { 51 | onClose(); 52 | } 53 | } 54 | 55 | document.addEventListener('keydown', keyListener); 56 | 57 | return () => document.removeEventListener('keydown', keyListener); 58 | }, [onClose, show]); 59 | 60 | return ( 61 | 62 | 63 | {show && ( 64 | 65 |
66 | 75 |
76 | 83 | 86 | {children} 87 | 88 |
89 |
90 |
91 | )} 92 |
93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/components/pomodoro/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, useEffect, useRef } from 'react'; 2 | import { IoRefresh, IoSettingsOutline } from 'react-icons/io5'; 3 | 4 | import { useLocalStorage } from '@/hooks/use-local-storage'; 5 | import { useAlarm } from '@/hooks/use-alarm'; 6 | import { Modal } from '../modal'; 7 | 8 | import styles from './pomodoro.module.css'; 9 | import { cn } from '@/helpers/styles'; 10 | 11 | export function PomodoroTimer() { 12 | const alarm = useAlarm(); 13 | 14 | const [selectedTab, setSelectedTab] = useState('pomodoro'); 15 | const [isRunning, setIsRunning] = useState(false); 16 | const [timer, setTimer] = useState(0); 17 | const interval = useRef | null>(null); 18 | 19 | const defaultTimes = useMemo( 20 | () => ({ 21 | long: 15 * 60, 22 | pomodoro: 25 * 60, 23 | short: 5 * 60, 24 | }), 25 | [], 26 | ); 27 | 28 | const [showSettings, setShowSettings] = useState(false); 29 | const [times, setTimes] = useLocalStorage>( 30 | 'timesy-pomodoro-setting', 31 | defaultTimes, 32 | ); 33 | 34 | const [completions, setCompletions] = useState>({ 35 | long: 0, 36 | pomodoro: 0, 37 | short: 0, 38 | }); 39 | 40 | const tabs = useMemo( 41 | () => [ 42 | { id: 'pomodoro', label: 'Pomodoro' }, 43 | { id: 'short', label: 'Break' }, 44 | { id: 'long', label: 'Long Break' }, 45 | ], 46 | [], 47 | ); 48 | 49 | useEffect(() => { 50 | if (isRunning) { 51 | if (interval.current) clearInterval(interval.current); 52 | 53 | interval.current = setInterval(() => { 54 | setTimer(prev => prev - 1); 55 | }, 1000); 56 | } else { 57 | if (interval.current) clearInterval(interval.current); 58 | } 59 | }, [isRunning]); 60 | 61 | const completionHandled = useRef(false); 62 | 63 | useEffect(() => { 64 | if (timer <= 0 && isRunning && !completionHandled.current) { 65 | if (interval.current) clearInterval(interval.current); 66 | 67 | alarm(); 68 | 69 | setIsRunning(false); 70 | setCompletions(prev => ({ 71 | ...prev, 72 | [selectedTab]: prev[selectedTab] + 1, 73 | })); 74 | 75 | completionHandled.current = true; 76 | } else if (timer > 0) { 77 | completionHandled.current = false; 78 | } 79 | }, [timer, selectedTab, isRunning, alarm]); 80 | 81 | useEffect(() => { 82 | const time = times[selectedTab] || 10; 83 | 84 | if (interval.current) clearInterval(interval.current); 85 | 86 | setIsRunning(false); 87 | setTimer(time); 88 | }, [selectedTab, times]); 89 | 90 | const toggleRunning = () => { 91 | if (isRunning) setIsRunning(false); 92 | else if (timer <= 0) { 93 | const time = times[selectedTab] || 10; 94 | 95 | setTimer(time); 96 | setIsRunning(true); 97 | } else setIsRunning(true); 98 | }; 99 | 100 | const restart = () => { 101 | if (interval.current) clearInterval(interval.current); 102 | 103 | const time = times[selectedTab] || 10; 104 | 105 | setIsRunning(false); 106 | setTimer(time); 107 | }; 108 | 109 | useEffect(() => { 110 | const minutes = Math.floor(timer / 60); 111 | const seconds = timer % 60; 112 | 113 | document.title = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} // Timesy`; 114 | 115 | return () => { 116 | document.title = 'Timesy: A Distraction-Free Online Timer'; 117 | }; 118 | }, [timer]); 119 | 120 | return ( 121 | <> 122 |
123 | 124 | 125 | 126 |
127 |
128 | 131 | 134 | 140 |
141 |
142 |
143 | 144 | setShowSettings(false)}> 145 | setShowSettings(false)} 148 | onChange={times => { 149 | setShowSettings(false); 150 | setTimes(times); 151 | }} 152 | /> 153 | 154 | 155 | ); 156 | } 157 | 158 | interface Tab { 159 | id: string; 160 | label: string; 161 | } 162 | 163 | interface TabsProps { 164 | onSelect: (id: string) => void; 165 | selectedTab: string; 166 | tabs: Tab[]; 167 | } 168 | 169 | export function Tabs({ onSelect, selectedTab, tabs }: TabsProps) { 170 | return ( 171 |
172 | {tabs.map(tab => ( 173 | 180 | ))} 181 |
182 | ); 183 | } 184 | 185 | interface TimerProps { 186 | completed: number; 187 | timer: number; 188 | } 189 | 190 | export function Timer({ completed, timer }: TimerProps) { 191 | const minutes = Math.floor(timer / 60); 192 | const seconds = timer % 60; 193 | 194 | return ( 195 |
196 |

{completed} completed

197 | {String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')} 198 |
199 | ); 200 | } 201 | 202 | interface SettingsProps { 203 | onCancel: () => void; 204 | onChange: (newTimes: Record) => void; 205 | times: Record; 206 | } 207 | 208 | export function Settings({ onCancel, onChange, times }: SettingsProps) { 209 | const [values, setValues] = useState>(times); 210 | 211 | useEffect(() => { 212 | setValues(times); 213 | }, [times]); 214 | 215 | const handleChange = (id: string) => (value: number | string) => { 216 | setValues(prev => ({ 217 | ...prev, 218 | [id]: typeof value === 'number' ? value * 60 : '', 219 | })); 220 | }; 221 | 222 | const handleSubmit = (e: React.FormEvent) => { 223 | e.preventDefault(); 224 | 225 | const newValues: Record = {}; 226 | 227 | Object.keys(values).forEach(name => { 228 | const value = values[name]; 229 | newValues[name] = typeof value === 'number' ? value : times[name]; 230 | }); 231 | 232 | onChange(newValues); 233 | }; 234 | 235 | const handleCancel = (e: React.MouseEvent) => { 236 | e.preventDefault(); 237 | 238 | onCancel(); 239 | }; 240 | 241 | return ( 242 |
243 |

Change Times

244 | 245 |
246 | 252 | 258 | 264 | 265 |
266 | 269 | 272 |
273 | 274 |
275 | ); 276 | } 277 | 278 | interface FieldProps { 279 | id: string; 280 | label: string; 281 | onChange: (value: number | string) => void; 282 | value: number | string; 283 | } 284 | 285 | function Field({ id, label, onChange, value }: FieldProps) { 286 | return ( 287 |
288 | 291 | { 299 | onChange(e.target.value === '' ? '' : Number(e.target.value)); 300 | }} 301 | /> 302 |
303 | ); 304 | } 305 | -------------------------------------------------------------------------------- /src/components/pomodoro/pomodoro.module.css: -------------------------------------------------------------------------------- 1 | .tabs { 2 | position: relative; 3 | top: 1px; 4 | z-index: 2; 5 | display: flex; 6 | column-gap: 8px; 7 | align-items: center; 8 | width: 100%; 9 | height: 45px; 10 | 11 | & button { 12 | display: flex; 13 | flex-grow: 1; 14 | align-items: center; 15 | justify-content: center; 16 | height: 100%; 17 | font-size: var(--font-sm); 18 | font-weight: 500; 19 | color: var(--color-foreground-subtle); 20 | cursor: pointer; 21 | background-color: var(--color-neutral-50); 22 | border: 1px solid var(--color-neutral-100); 23 | border-bottom-color: var(--color-neutral-200); 24 | border-radius: 8px 8px 0 0; 25 | transition: 0.2s; 26 | 27 | &:hover { 28 | border-color: var(--color-neutral-200); 29 | border-bottom-color: var(--color-neutral-200); 30 | } 31 | 32 | &.selected { 33 | color: var(--color-foreground); 34 | background: linear-gradient( 35 | var(--color-neutral-100), 36 | var(--color-neutral-50) 37 | ); 38 | border-color: var(--color-neutral-200); 39 | border-bottom-color: var(--color-neutral-50); 40 | } 41 | } 42 | } 43 | 44 | .timer { 45 | position: relative; 46 | display: flex; 47 | align-items: center; 48 | justify-content: center; 49 | width: 100%; 50 | height: 160px; 51 | font-family: var(--font-mono); 52 | font-size: var(--font-2xlg); 53 | font-weight: 700; 54 | background-color: var(--color-neutral-50); 55 | border: 1px solid var(--color-neutral-200); 56 | border-radius: 0 0 8px 8px; 57 | 58 | & p { 59 | position: absolute; 60 | top: 8px; 61 | left: 50%; 62 | padding: 4px 16px; 63 | font-family: var(--font-body); 64 | font-size: var(--font-2xsm); 65 | font-weight: 400; 66 | color: var(--color-foreground-subtle); 67 | background-color: var(--color-neutral-100); 68 | border: 1px solid var(--color-neutral-200); 69 | border-radius: 50px; 70 | transform: translateX(-50%); 71 | } 72 | } 73 | 74 | .buttons { 75 | display: flex; 76 | column-gap: 4px; 77 | align-items: center; 78 | margin-top: 12px; 79 | 80 | & button { 81 | display: flex; 82 | align-items: center; 83 | justify-content: center; 84 | height: 40px; 85 | font-weight: 500; 86 | cursor: pointer; 87 | border: none; 88 | border-radius: 8px; 89 | 90 | &.play { 91 | flex-grow: 1; 92 | color: var(--color-neutral-50); 93 | background-color: var(--color-neutral-950); 94 | box-shadow: inset 0 -3px 0 var(--color-neutral-700); 95 | } 96 | 97 | &.reset, 98 | &.setting { 99 | width: 40px; 100 | color: var(--color-foreground-subtle); 101 | background-color: var(--color-neutral-200); 102 | box-shadow: inset 0 -3px 0 var(--color-neutral-100); 103 | } 104 | } 105 | } 106 | 107 | .setting { 108 | & .title { 109 | margin-bottom: 16px; 110 | font-family: var(--font-display); 111 | font-size: var(--font-lg); 112 | } 113 | 114 | & .form { 115 | display: flex; 116 | flex-direction: column; 117 | 118 | & .field { 119 | display: flex; 120 | flex-direction: column; 121 | row-gap: 8px; 122 | margin-bottom: 16px; 123 | 124 | & .label { 125 | font-size: var(--font-sm); 126 | color: var(--color-foreground); 127 | 128 | & span { 129 | color: var(--color-foreground-subtle); 130 | } 131 | } 132 | 133 | & .input { 134 | display: block; 135 | height: 44px; 136 | padding: 0 8px; 137 | color: var(--color-foreground); 138 | background-color: var(--color-neutral-50); 139 | border: 1px solid var(--color-neutral-200); 140 | border-radius: 8px; 141 | outline: none; 142 | 143 | &:focus-visible { 144 | outline: 2px solid var(--color-neutral-400); 145 | outline-offset: 2px; 146 | } 147 | } 148 | } 149 | 150 | & .buttons { 151 | display: flex; 152 | column-gap: 8px; 153 | align-items: center; 154 | justify-content: flex-end; 155 | 156 | & button { 157 | display: flex; 158 | align-items: center; 159 | justify-content: center; 160 | height: 36px; 161 | padding: 0 16px; 162 | font-size: var(--font-sm); 163 | font-weight: 500; 164 | color: var(--color-foreground); 165 | cursor: pointer; 166 | background-color: var(--color-neutral-200); 167 | border: none; 168 | border-radius: 8px; 169 | outline: none; 170 | 171 | &:focus-visible { 172 | outline: 2px solid var(--color-neutral-400); 173 | outline-offset: 2px; 174 | } 175 | 176 | &.primary { 177 | color: var(--color-neutral-100); 178 | background-color: var(--color-neutral-950); 179 | } 180 | } 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/components/portal/index.ts: -------------------------------------------------------------------------------- 1 | export { Portal } from './portal'; 2 | -------------------------------------------------------------------------------- /src/components/portal/portal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | interface PortalProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | export function Portal({ children }: PortalProps) { 9 | const [isClientSide, setIsClientSide] = useState(false); 10 | 11 | useEffect(() => setIsClientSide(true), []); 12 | 13 | return isClientSide ? createPortal(children, document.body) : null; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/reload/index.ts: -------------------------------------------------------------------------------- 1 | export { Reload } from './reload'; 2 | -------------------------------------------------------------------------------- /src/components/reload/reload-modal.tsx: -------------------------------------------------------------------------------- 1 | import { useRegisterSW } from 'virtual:pwa-register/react'; // eslint-disable-line 2 | 3 | import { Modal } from '@/components/modal'; 4 | 5 | import styles from './reload.module.css'; 6 | 7 | export function ReloadModal() { 8 | const { 9 | needRefresh: [needRefresh, setNeedRefresh], 10 | updateServiceWorker, 11 | } = useRegisterSW(); 12 | 13 | const close = () => { 14 | setNeedRefresh(false); 15 | }; 16 | 17 | return ( 18 | 19 |

New Content

20 |

21 | New content available, click on reload button to update. 22 |

23 | 24 |
25 | 26 | 27 | 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/reload/reload.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-family: var(--font-display); 3 | font-size: var(--font-lg); 4 | } 5 | 6 | .desc { 7 | margin-top: 8px; 8 | color: var(--color-foreground-subtle); 9 | } 10 | 11 | .buttons { 12 | display: flex; 13 | column-gap: 8px; 14 | align-items: center; 15 | justify-content: flex-end; 16 | margin-top: 16px; 17 | 18 | & button { 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | height: 40px; 23 | padding: 0 12px; 24 | font-size: var(--font-sm); 25 | font-weight: 500; 26 | color: var(--color-foreground-subtle); 27 | background-color: var(--color-neutral-200); 28 | border: none; 29 | border-radius: 8px; 30 | 31 | &.primary { 32 | color: var(--color-neutral-50); 33 | background-color: var(--color-neutral-950); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/reload/reload.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { ReloadModal } from './reload-modal'; 4 | 5 | export function Reload() { 6 | const [isBrowser, setIsBrowser] = useState(false); 7 | 8 | useEffect(() => setIsBrowser(true), []); 9 | 10 | return isBrowser ? : null; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/snackbar/index.ts: -------------------------------------------------------------------------------- 1 | export { Snackbar } from './snackbar'; 2 | -------------------------------------------------------------------------------- /src/components/snackbar/snackbar.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | position: fixed; 3 | bottom: 20px; 4 | left: 0; 5 | z-index: 100; 6 | width: 100%; 7 | pointer-events: none; 8 | 9 | & .snackbar { 10 | width: max-content; 11 | max-width: 90%; 12 | padding: 12px 16px; 13 | margin: 0 auto; 14 | font-size: var(--font-sm); 15 | pointer-events: fill; 16 | background-color: var(--color-neutral-100); 17 | border: 1px solid var(--color-neutral-200); 18 | border-radius: 8px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/snackbar/snackbar.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | 3 | import styles from './snackbar.module.css'; 4 | 5 | interface SnackbarProps { 6 | message: string; 7 | } 8 | 9 | export function Snackbar({ message }: SnackbarProps) { 10 | const variants = { 11 | hidden: { opacity: 0, y: 20 }, 12 | show: { opacity: 1, y: 0 }, 13 | }; 14 | 15 | return ( 16 |
17 | 24 | {message} 25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/store-consumer/index.ts: -------------------------------------------------------------------------------- 1 | export { StoreConsumer } from './store-consumer'; 2 | -------------------------------------------------------------------------------- /src/components/store-consumer/store-consumer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useTimers } from '@/stores/timers'; 4 | import { useSettings } from '@/stores/settings'; 5 | 6 | interface StoreConsumerProps { 7 | children: React.ReactNode; 8 | } 9 | 10 | export function StoreConsumer({ children }: StoreConsumerProps) { 11 | useEffect(() => { 12 | useTimers.persist.rehydrate(); 13 | useSettings.persist.rehydrate(); 14 | }, []); 15 | 16 | return <>{children}; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/timers/index.ts: -------------------------------------------------------------------------------- 1 | export { Timers } from './timers'; 2 | -------------------------------------------------------------------------------- /src/components/timers/notice/index.ts: -------------------------------------------------------------------------------- 1 | export { Notice } from './notice'; 2 | -------------------------------------------------------------------------------- /src/components/timers/notice/notice.module.css: -------------------------------------------------------------------------------- 1 | .notice { 2 | padding: 16px; 3 | margin-top: 16px; 4 | font-size: var(--font-sm); 5 | line-height: 1.65; 6 | color: var(--color-foreground-subtle); 7 | text-align: center; 8 | border: 1px dashed var(--color-neutral-200); 9 | border-radius: 8px; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/timers/notice/notice.tsx: -------------------------------------------------------------------------------- 1 | import styles from './notice.module.css'; 2 | 3 | export function Notice() { 4 | return ( 5 |

6 | Please do not close this tab while timers are running, otherwise all 7 | timers will be stopped. 8 |

9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/timers/timer/index.ts: -------------------------------------------------------------------------------- 1 | export { Timer } from './timer'; 2 | -------------------------------------------------------------------------------- /src/components/timers/timer/timer.module.css: -------------------------------------------------------------------------------- 1 | .timer { 2 | position: relative; 3 | padding: 8px; 4 | overflow: hidden; 5 | background-color: var(--color-neutral-100); 6 | border: 1px solid var(--color-neutral-200); 7 | border-radius: 8px; 8 | 9 | &:not(:last-of-type) { 10 | margin-bottom: 24px; 11 | } 12 | 13 | & .header { 14 | position: relative; 15 | top: -8px; 16 | width: 100%; 17 | 18 | & .bar { 19 | height: 2px; 20 | margin: 0 -8px; 21 | background-color: var(--color-neutral-200); 22 | 23 | & .completed { 24 | height: 100%; 25 | background-color: var(--color-neutral-500); 26 | transition: 0.2s; 27 | } 28 | } 29 | } 30 | 31 | & .footer { 32 | display: flex; 33 | column-gap: 4px; 34 | align-items: center; 35 | 36 | & .control { 37 | display: flex; 38 | flex-grow: 1; 39 | column-gap: 4px; 40 | align-items: center; 41 | height: 40px; 42 | padding: 4px; 43 | background-color: var(--color-neutral-50); 44 | border: 1px solid var(--color-neutral-200); 45 | border-radius: 4px; 46 | 47 | & .input { 48 | flex-grow: 1; 49 | width: 100%; 50 | min-width: 0; 51 | height: 100%; 52 | padding: 0 8px; 53 | color: var(--color-foreground-subtle); 54 | background-color: transparent; 55 | border: none; 56 | border-radius: 4px; 57 | outline: none; 58 | 59 | &.finished { 60 | text-decoration: line-through; 61 | } 62 | } 63 | 64 | & .button { 65 | display: flex; 66 | align-items: center; 67 | justify-content: center; 68 | height: 100%; 69 | aspect-ratio: 1 / 1; 70 | color: var(--color-foreground); 71 | cursor: pointer; 72 | background-color: var(--color-neutral-200); 73 | border: 1px solid var(--color-neutral-300); 74 | border-radius: 2px; 75 | outline: none; 76 | transition: 0.2s; 77 | 78 | &.reset { 79 | background-color: var(--color-neutral-100); 80 | border: none; 81 | } 82 | 83 | &:disabled, 84 | &.disabled { 85 | cursor: not-allowed; 86 | opacity: 0.6; 87 | } 88 | } 89 | } 90 | 91 | & .delete { 92 | display: flex; 93 | align-items: center; 94 | justify-content: center; 95 | width: 38px; 96 | height: 38px; 97 | color: #f43f5e; 98 | cursor: pointer; 99 | background-color: rgb(244 63 94 / 10%); 100 | border: none; 101 | border-radius: 4px; 102 | outline: none; 103 | transition: 0.2s; 104 | 105 | &.disabled { 106 | cursor: not-allowed; 107 | opacity: 0.6; 108 | } 109 | } 110 | } 111 | 112 | & .left { 113 | display: flex; 114 | align-items: center; 115 | justify-content: center; 116 | height: 120px; 117 | font-family: var(--font-mono); 118 | font-size: var(--font-2xlg); 119 | font-weight: 700; 120 | cursor: pointer; 121 | 122 | & span { 123 | color: var(--color-foreground-subtle); 124 | } 125 | } 126 | } 127 | 128 | .shortcut { 129 | display: flex; 130 | column-gap: 8px; 131 | align-items: center; 132 | padding: 8px; 133 | margin-top: 8px; 134 | font-size: var(--font-xsm); 135 | color: var(--color-foreground-subtle); 136 | border: 1px dashed var(--color-neutral-300); 137 | border-radius: 4px; 138 | 139 | & .key { 140 | padding: 4px 8px; 141 | font-size: var(--font-2xsm); 142 | font-weight: 500; 143 | color: var(--color-foreground); 144 | background-color: var(--color-neutral-50); 145 | border: 1px solid var(--color-neutral-200); 146 | border-radius: 4px; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/components/timers/timer/timer.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useMemo, useState, useEffect, useCallback } from 'react'; 2 | import { IoPlay, IoPause, IoRefresh, IoTrashOutline } from 'react-icons/io5'; 3 | import { useHotkeys } from 'react-hotkeys-hook'; 4 | 5 | import { Toolbar } from './toolbar'; 6 | 7 | import { useTimers } from '@/stores/timers'; 8 | import { useAlarm } from '@/hooks/use-alarm'; 9 | import { useSnackbar } from '@/contexts/snackbar'; 10 | import { padNumber } from '@/helpers/number'; 11 | import { cn } from '@/helpers/styles'; 12 | 13 | import styles from './timer.module.css'; 14 | 15 | interface TimerProps { 16 | id: string; 17 | index: number; 18 | } 19 | 20 | export function Timer({ id, index }: TimerProps) { 21 | const intervalRef = useRef | null>(null); 22 | const lastActiveTimeRef = useRef(null); 23 | const lastStateRef = useRef<{ spent: number; total: number } | null>(null); 24 | 25 | const [isRunning, setIsRunning] = useState(false); 26 | 27 | const { autoStart, first, last, name, spent, total } = useTimers(state => 28 | state.getTimer(id), 29 | ); 30 | const tick = useTimers(state => state.tick); 31 | const rename = useTimers(state => state.rename); 32 | const reset = useTimers(state => state.reset); 33 | const deleteTimer = useTimers(state => state.delete); 34 | const removeAutoStart = useTimers(state => state.removeAutoStart); 35 | 36 | const left = useMemo(() => total - spent, [total, spent]); 37 | 38 | const hours = useMemo(() => Math.floor(left / 3600), [left]); 39 | const minutes = useMemo(() => Math.floor((left % 3600) / 60), [left]); 40 | const seconds = useMemo(() => left % 60, [left]); 41 | 42 | const [isReversed, setIsReversed] = useState(false); 43 | 44 | const spentHours = useMemo(() => Math.floor(spent / 3600), [spent]); 45 | const spentMinutes = useMemo(() => Math.floor((spent % 3600) / 60), [spent]); 46 | const spentSeconds = useMemo(() => spent % 60, [spent]); 47 | 48 | const playAlarm = useAlarm(); 49 | 50 | const showSnackbar = useSnackbar(); 51 | 52 | const handleStart = useCallback(() => { 53 | if (left > 0) setIsRunning(true); 54 | }, [left]); 55 | 56 | const handlePause = () => setIsRunning(false); 57 | 58 | const handleToggle = () => { 59 | if (isRunning) handlePause(); 60 | else handleStart(); 61 | }; 62 | 63 | const handleReset = () => { 64 | if (spent === 0) return; 65 | 66 | if (isRunning) return showSnackbar('Please first stop the timer.'); 67 | 68 | setIsRunning(false); 69 | reset(id); 70 | }; 71 | 72 | const handleDelete = () => { 73 | if (isRunning) return showSnackbar('Please first stop the timer.'); 74 | 75 | deleteTimer(id); 76 | }; 77 | 78 | useEffect(() => { 79 | if (isRunning) { 80 | if (intervalRef.current) clearInterval(intervalRef.current); 81 | 82 | intervalRef.current = setInterval(() => tick(id), 1000); 83 | } 84 | 85 | return () => { 86 | if (intervalRef.current) clearInterval(intervalRef.current); 87 | }; 88 | }, [isRunning, tick, id]); 89 | 90 | useEffect(() => { 91 | if (left === 0 && isRunning) { 92 | setIsRunning(false); 93 | playAlarm(); 94 | 95 | if (intervalRef.current) clearInterval(intervalRef.current); 96 | } 97 | }, [left, isRunning, playAlarm]); 98 | 99 | useEffect(() => { 100 | const handleBlur = () => { 101 | if (isRunning) { 102 | lastActiveTimeRef.current = Date.now(); 103 | lastStateRef.current = { spent, total }; 104 | } 105 | }; 106 | 107 | const handleFocus = () => { 108 | if (isRunning && lastActiveTimeRef.current && lastStateRef.current) { 109 | const elapsed = Math.floor( 110 | (Date.now() - lastActiveTimeRef.current) / 1000, 111 | ); 112 | const previousLeft = 113 | lastStateRef.current.total - lastStateRef.current.spent; 114 | const currentLeft = left; 115 | const correctedLeft = previousLeft - elapsed; 116 | 117 | if (correctedLeft < currentLeft) { 118 | tick(id, currentLeft - correctedLeft); 119 | } 120 | 121 | lastActiveTimeRef.current = null; 122 | lastStateRef.current = null; 123 | } 124 | }; 125 | 126 | window.addEventListener('blur', handleBlur); 127 | window.addEventListener('focus', handleFocus); 128 | 129 | return () => { 130 | window.removeEventListener('blur', handleBlur); 131 | window.removeEventListener('focus', handleFocus); 132 | }; 133 | }, [isRunning, tick, id, spent, total, left]); 134 | 135 | useEffect(() => { 136 | if (autoStart) { 137 | handleStart(); 138 | removeAutoStart(id); 139 | } 140 | }, [autoStart, handleStart, removeAutoStart, id]); 141 | 142 | useHotkeys(`shift+${index + 1}`, () => handleToggle(), { 143 | enabled: index < 9, 144 | }); 145 | 146 | useHotkeys(`shift+alt+${index + 1}`, () => handleReset(), { 147 | enabled: index < 9, 148 | }); 149 | 150 | return ( 151 |
152 |
153 |
154 |
158 |
159 |
160 | 161 | 162 | 163 |
setIsReversed(prev => !prev)} 167 | onKeyDown={() => setIsReversed(prev => !prev)} 168 | > 169 | {!isReversed ? ( 170 | <> 171 | {padNumber(hours)} 172 | : 173 | {padNumber(minutes)} 174 | : 175 | {padNumber(seconds)} 176 | 177 | ) : ( 178 | <> 179 | - 180 | {padNumber(spentHours)} 181 | : 182 | {padNumber(spentMinutes)} 183 | : 184 | {padNumber(spentSeconds)} 185 | 186 | )} 187 |
188 | 189 |
190 |
191 | rename(id, e.target.value)} 197 | /> 198 | 199 | 210 | 211 | 218 |
219 | 220 | 227 |
228 | 229 | {index < 9 && ( 230 |
231 | Shift 232 | + 233 | {index + 1} 234 | to toggle play. 235 |
236 | )} 237 |
238 | ); 239 | } 240 | -------------------------------------------------------------------------------- /src/components/timers/timer/toolbar/index.ts: -------------------------------------------------------------------------------- 1 | export { Toolbar } from './toolbar'; 2 | -------------------------------------------------------------------------------- /src/components/timers/timer/toolbar/toolbar.module.css: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | position: absolute; 3 | top: 12px; 4 | right: 12px; 5 | display: flex; 6 | column-gap: 4px; 7 | align-items: center; 8 | height: 30px; 9 | padding: 4px; 10 | background-color: var(--color-neutral-50); 11 | border: 1px solid var(--color-neutral-200); 12 | border-radius: 4px; 13 | 14 | & button { 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | height: 100%; 19 | aspect-ratio: 1 / 1; 20 | font-size: var(--font-xsm); 21 | color: var(--color-foreground-subtle); 22 | cursor: pointer; 23 | background-color: transparent; 24 | border: none; 25 | transition: 0.2s; 26 | 27 | &:disabled { 28 | cursor: not-allowed; 29 | opacity: 0.2; 30 | } 31 | 32 | &:not(:disabled):hover { 33 | color: var(--color-foreground); 34 | background-color: var(--color-neutral-100); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/timers/timer/toolbar/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io'; 2 | 3 | import { useTimers } from '@/stores/timers'; 4 | 5 | import styles from './toolbar.module.css'; 6 | 7 | interface ToolbarProps { 8 | first: boolean; 9 | id: string; 10 | last: boolean; 11 | } 12 | 13 | export function Toolbar({ first, id, last }: ToolbarProps) { 14 | const moveUp = useTimers(state => state.moveUp); 15 | const moveDown = useTimers(state => state.moveDown); 16 | 17 | return ( 18 |
19 | 28 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/timers/timers.module.css: -------------------------------------------------------------------------------- 1 | .timers { 2 | margin-top: 48px; 3 | 4 | & > header { 5 | display: flex; 6 | column-gap: 12px; 7 | align-items: center; 8 | margin-bottom: 16px; 9 | 10 | & .title { 11 | font-family: var(--font-display); 12 | font-size: var(--font-lg); 13 | line-height: 1; 14 | } 15 | 16 | & .line { 17 | flex-grow: 1; 18 | height: 0; 19 | border-top: 1px dashed var(--color-neutral-200); 20 | } 21 | 22 | & .spent { 23 | font-size: var(--font-sm); 24 | color: var(--color-foreground-subtle); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/timers/timers.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useAutoAnimate } from '@formkit/auto-animate/react'; 3 | 4 | import { Timer } from './timer'; 5 | import { Notice } from './notice'; 6 | 7 | import { useTimers } from '@/stores/timers'; 8 | 9 | import styles from './timers.module.css'; 10 | 11 | export function Timers() { 12 | const [animationParent] = useAutoAnimate(); 13 | const [animationList] = useAutoAnimate(); 14 | 15 | const timers = useTimers(state => state.timers); 16 | const spent = useTimers(state => state.spent()); 17 | const total = useTimers(state => state.total()); 18 | 19 | const spentMinutes = useMemo(() => Math.floor(spent / 60), [spent]); 20 | const totalMinutes = useMemo(() => Math.floor(total / 60), [total]); 21 | 22 | return ( 23 |
24 | {timers.length > 0 ? ( 25 |
26 |
27 |

Timers

28 |
29 | {totalMinutes > 0 && ( 30 |

31 | {spentMinutes} / {totalMinutes} Minute 32 | {totalMinutes !== 1 && 's'} 33 |

34 | )} 35 |
36 | 37 | {timers.map((timer, index) => ( 38 | 39 | ))} 40 | 41 | 42 |
43 | ) : null} 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/contexts/snackbar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useState, 4 | useCallback, 5 | useRef, 6 | useContext, 7 | } from 'react'; 8 | import { AnimatePresence } from 'framer-motion'; 9 | 10 | import { Snackbar } from '@/components/snackbar'; 11 | 12 | export const SnackbarContext = createContext< 13 | (message: string, duration?: number) => void 14 | >(() => {}); 15 | 16 | export const useSnackbar = () => useContext(SnackbarContext); 17 | 18 | interface SnackbarProviderProps { 19 | children: React.ReactNode; 20 | } 21 | 22 | export function SnackbarProvider({ children }: SnackbarProviderProps) { 23 | const [message, setMessage] = useState(''); 24 | const [isVisible, setIsVisible] = useState(false); 25 | const timeout = useRef | null>(null); 26 | 27 | const show = useCallback((message: string, duration = 5000) => { 28 | setMessage(message); 29 | setIsVisible(true); 30 | 31 | if (timeout.current) clearTimeout(timeout.current); 32 | 33 | timeout.current = setTimeout(() => { 34 | setMessage(''); 35 | setIsVisible(false); 36 | }, duration); 37 | }, []); 38 | 39 | return ( 40 | 41 | {children} 42 | 43 | 44 | {isVisible && } 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /src/helpers/number.ts: -------------------------------------------------------------------------------- 1 | export function padNumber(number: number, maxLength: number = 2): string { 2 | return number.toString().padStart(maxLength, '0'); 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/styles.ts: -------------------------------------------------------------------------------- 1 | type className = undefined | null | false | string; 2 | 3 | export function cn(...classNames: Array): string { 4 | const className = classNames.filter(className => !!className).join(' '); 5 | 6 | return className; 7 | } 8 | -------------------------------------------------------------------------------- /src/hooks/use-alarm.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | 3 | import { useSound } from './use-sound'; 4 | import { useAlarmStore } from '@/stores/alarm'; 5 | import { useSettings } from '@/stores/settings'; 6 | 7 | export function useAlarm() { 8 | const { play: playSound, setVolume } = useSound('/sounds/alarm.mp3', 1); 9 | const isPlaying = useAlarmStore(state => state.isPlaying); 10 | const play = useAlarmStore(state => state.play); 11 | const stop = useAlarmStore(state => state.stop); 12 | 13 | const volume = useSettings(state => state.volume); 14 | 15 | useEffect(() => { 16 | setVolume(volume); 17 | }, [volume, setVolume]); 18 | 19 | const playAlarm = useCallback(() => { 20 | if (!isPlaying) { 21 | playSound(stop); 22 | play(); 23 | } 24 | }, [isPlaying, playSound, play, stop]); 25 | 26 | return playAlarm; 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; 2 | 3 | type SetValue = Dispatch>; 4 | 5 | /** 6 | * A custom React hook to manage state with localStorage persistence. 7 | * 8 | * @template T 9 | * @param {string} key - The key under which the value is stored in localStorage. 10 | * @param {T} fallback - The fallback value to use if there is no value in localStorage. 11 | * @returns {[T, SetValue]} An array containing the stateful value and a function to update it. 12 | */ 13 | export function useLocalStorage(key: string, fallback: T): [T, SetValue] { 14 | const [value, setValue] = useState(fallback); 15 | 16 | useEffect(() => { 17 | const value = localStorage.getItem(key); 18 | 19 | if (!value) return; 20 | 21 | let parsed; 22 | 23 | try { 24 | parsed = JSON.parse(value); 25 | } catch (error) { 26 | parsed = fallback; 27 | } 28 | 29 | setValue(parsed); 30 | }, [key, fallback]); 31 | 32 | useEffect(() => { 33 | localStorage.setItem(key, JSON.stringify(value)); 34 | }, [value, key]); 35 | 36 | return [value, setValue]; 37 | } 38 | -------------------------------------------------------------------------------- /src/hooks/use-sound.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useEffect, useCallback } from 'react'; 2 | import { Howl } from 'howler'; 3 | 4 | import { useSSR } from './use-ssr'; 5 | 6 | export function useSound(src: string, volume: number = 1) { 7 | const { isBrowser } = useSSR(); 8 | 9 | const sound = useMemo(() => { 10 | let sound: Howl | null = null; 11 | 12 | if (isBrowser) { 13 | sound = new Howl({ 14 | html5: true, 15 | src: src, 16 | }); 17 | } 18 | 19 | return sound; 20 | }, [src, isBrowser]); 21 | 22 | useEffect(() => { 23 | if (sound) sound.volume(typeof volume === 'number' ? volume : 1); 24 | }, [sound, volume]); 25 | 26 | const play = useCallback( 27 | (cb?: () => void) => { 28 | if (sound) { 29 | if (!sound.playing()) { 30 | sound.play(); 31 | 32 | if (typeof cb === 'function') sound.once('end', cb); 33 | } 34 | } 35 | }, 36 | [sound], 37 | ); 38 | 39 | const stop = useCallback(() => { 40 | if (sound) sound.stop(); 41 | }, [sound]); 42 | 43 | const pause = useCallback(() => { 44 | if (sound) sound.pause(); 45 | }, [sound]); 46 | 47 | const setVolume = useCallback( 48 | (volume: number) => { 49 | if (sound) sound.volume(volume); 50 | }, 51 | [sound], 52 | ); 53 | 54 | const control = useMemo( 55 | () => ({ pause, play, setVolume, stop }), 56 | [play, stop, pause, setVolume], 57 | ); 58 | 59 | return control; 60 | } 61 | -------------------------------------------------------------------------------- /src/hooks/use-ssr.ts: -------------------------------------------------------------------------------- 1 | export function useSSR() { 2 | const isDOM = 3 | typeof window !== 'undefined' && 4 | window.document && 5 | window.document.documentElement; 6 | 7 | return { 8 | isBrowser: isDOM, 9 | isServer: !isDOM, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/layouts/layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { pwaInfo } from 'virtual:pwa-info'; // eslint-disable-line 3 | 4 | import { Reload } from '@/components/reload'; 5 | 6 | import '@/styles/global.css'; 7 | 8 | interface Props { 9 | description?: string; 10 | title?: string; 11 | } 12 | 13 | const title = Astro.props.title || 'Timesy: A Distraction-Free Online Timer'; 14 | const description = 15 | Astro.props.description || `A Distraction-free Online Timer.`; 16 | --- 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {title} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {pwaInfo && } 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '@/layouts/layout.astro'; 3 | 4 | import Hero from '@/components/hero.astro'; 5 | import About from '@/components/about.astro'; 6 | import Footer from '@/components/footer.astro'; 7 | 8 | import { App } from '@/components/app'; 9 | --- 10 | 11 | 12 | 13 | 14 | 15 |