diff options
-rw-r--r-- | .bsb.lock | 1 | ||||
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | .gitlab-ci.yml | 10 | ||||
-rw-r--r-- | .ocamlformat | 1 | ||||
-rw-r--r-- | .tmuxinator.yml | 14 | ||||
-rw-r--r-- | LICENSE | 19 | ||||
-rw-r--r-- | Makefile | 22 | ||||
-rw-r--r-- | README.md | 29 | ||||
-rw-r--r-- | bsconfig.json | 22 | ||||
-rwxr-xr-x | deploy | 12 | ||||
-rwxr-xr-x | dev | 27 | ||||
-rw-r--r-- | package-lock.json | 150 | ||||
-rw-r--r-- | package.json | 14 | ||||
-rw-r--r-- | public/icon.png | bin | 0 -> 886 bytes | |||
-rw-r--r-- | public/index.html | 101 | ||||
-rw-r--r-- | public/main.css | 175 | ||||
-rw-r--r-- | public/sounds/c3.mp3 | bin | 0 -> 47469 bytes | |||
-rw-r--r-- | public/sounds/c4.mp3 | bin | 0 -> 57357 bytes | |||
-rw-r--r-- | public/sounds/c5.mp3 | bin | 0 -> 65742 bytes | |||
-rw-r--r-- | rollup.config.js | 13 | ||||
-rw-r--r-- | shell.nix | 17 | ||||
-rw-r--r-- | src/Dom/Document.ml | 4 | ||||
-rw-r--r-- | src/Dom/Element.ml | 14 | ||||
-rw-r--r-- | src/Dom/Event.ml | 3 | ||||
-rw-r--r-- | src/Dom/EventTarget.ml | 1 | ||||
-rw-r--r-- | src/animation.ml | 26 | ||||
-rw-r--r-- | src/arc.ml | 23 | ||||
-rw-r--r-- | src/audio.ml | 13 | ||||
-rw-r--r-- | src/config.ml | 96 | ||||
-rw-r--r-- | src/duration.ml | 6 | ||||
-rw-r--r-- | src/main.ml | 18 | ||||
-rw-r--r-- | src/option.ml | 1 | ||||
-rw-r--r-- | src/step.ml | 40 | ||||
-rw-r--r-- | src/string.ml | 1 | ||||
-rw-r--r-- | src/timer.ml | 116 |
35 files changed, 994 insertions, 0 deletions
diff --git a/.bsb.lock b/.bsb.lock new file mode 100644 index 0000000..8aaceb7 --- /dev/null +++ b/.bsb.lock @@ -0,0 +1 @@ +12302
\ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3f8b12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +lib/ +.merlin +*.bs.js +public/main.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a872f80 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,10 @@ +image: alpine:latest +pages: + stage: deploy + script: + - echo 'Nothing to do...' + artifacts: + paths: + - public + only: + - pages diff --git a/.ocamlformat b/.ocamlformat new file mode 100644 index 0000000..df48a53 --- /dev/null +++ b/.ocamlformat @@ -0,0 +1 @@ +version=0.12 diff --git a/.tmuxinator.yml b/.tmuxinator.yml new file mode 100644 index 0000000..58bc8d4 --- /dev/null +++ b/.tmuxinator.yml @@ -0,0 +1,14 @@ +name: tabata-timer +startup_window: main + +windows: + - console: + - clear + - main: + panes: + - ocaml: + - ./dev watch-ocaml + - js: + - ./dev watch-js + - server: + - python -m http.server --directory public @@ -0,0 +1,19 @@ +Copyright (c) 2020 Joris Guyonvarch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6c9a2b3 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +export PATH := node_modules/.bin:$(PATH) + +build: public/main.js + +public/main.js: node_modules $(shell find src \( -type d -o \( -type f -a -regex ".*\.ml" \) \)) + @echo "Building $@" + @bsb -make-world + @rollup --config rollup.config.js + @terser $@ --output $@ --compress --mangle + +node_modules: package.json + @bsb -init init + @mv init/node_modules . + @rm -rf init + @npm install + @touch -c node_modules + +clean: + @echo "Cleaning" + @rm -f public/main.js + @rm -rf node_modules lib + @find src -name '*.bs.js' -exec rm {} \; diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1d9c26 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Tabata timer + +Available at [https://guyonvarch.gitlab.io/tabata-timer](https://guyonvarch.gitlab.io/tabata-timer). + +## Gettings started + +Start the environment with: + +```bash +./dev start +``` + +Later, stop the environment with: + +```bash +./dev stop +``` + +## Deploy + +```bash +nix-shell --run ./deploy +``` + +## Bucklescript links + +- [Documentation](https://bucklescript.github.io/docs/en/interop-overview) +- [Ocaml std API](https://caml.inria.fr/pub/docs/manual-ocaml-4.02/stdlib.html) +- [Libraries](https://bucklescript.github.io/bucklescript/api/index.html) diff --git a/bsconfig.json b/bsconfig.json new file mode 100644 index 0000000..db0c8d9 --- /dev/null +++ b/bsconfig.json @@ -0,0 +1,22 @@ +{ + "name": "tabata-timer", + "version": "0.1.0", + "sources": { + "dir": "src", + "subdirs": true + }, + "package-specs": { + "module": "es6", + "in-source": true + }, + "suffix": ".bs.js", + "bs-dependencies": [], + "warnings": { + "number": "+A-42-40-4", + "error": "+A-40-4" + }, + "bsc-flags": [ + "-bs-super-errors" + ], + "refmt": 3 +} @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e + +# Build +git branch -D pages || true +git checkout -b pages +make clean build +git add --force public/main.js +git commit -m "Deploy pages" +git push --force origin pages +git checkout master +git branch -D pages @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +cd "$(dirname $0)" +CMD="$1" +PROJECT="tabata-timer" + +if [ "$CMD" = "start" ]; then + + nix-shell --run "make node_modules && tmuxinator local" + +elif [ "$CMD" = "stop" ]; then + + nix-shell --run "tmux kill-session -t $PROJECT" + +elif [ "$CMD" = "watch-ocaml" ]; then + + bsb -make-world -w + +elif [ "$CMD" = "watch-js" ]; then + + node_modules/.bin/rollup --watch --config rollup.config.js + +else + + echo "Usage: $0 start|stop|watch-ocaml|watch-js" + exit 1 + +fi diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f20e082 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,150 @@ +{ + "name": "tabata-timer", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@rollup/plugin-node-resolve": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.1.tgz", + "integrity": "sha512-14ddhD7TnemeHE97a4rLOhobfYvUVcaYuqTnL8Ti7Jxi9V9Jr5LY7Gko4HZ5k4h4vqQM0gBQt6tsp9xXW94WPA==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.6", + "@types/resolve": "0.0.8", + "builtin-modules": "^3.1.0", + "is-module": "^1.0.0", + "resolve": "^1.14.2" + } + }, + "@rollup/pluginutils": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.0.8.tgz", + "integrity": "sha512-rYGeAc4sxcZ+kPG/Tw4/fwJODC3IXHYDH4qusdN/b6aLw5LPUbzpecYbEJh4sVQGPFJxd2dBU4kc1H3oy9/bnw==", + "dev": true, + "requires": { + "estree-walker": "^1.0.1" + } + }, + "@types/estree": { + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.42.tgz", + "integrity": "sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==", + "dev": true + }, + "@types/node": { + "version": "13.7.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.7.1.tgz", + "integrity": "sha512-Zq8gcQGmn4txQEJeiXo/KiLpon8TzAl0kmKH4zdWctPj05nWwp1ClMdAVEloqrQKfaC48PNLdgN/aVaLqUrluA==", + "dev": true + }, + "@types/resolve": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", + "integrity": "sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "acorn": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", + "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", + "dev": true + }, + "bs-platform": { + "version": "7.0.1", + "dev": true + }, + "bs-webapi": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/bs-webapi/-/bs-webapi-0.15.7.tgz", + "integrity": "sha512-Mu8H+9DRIPK6VuqgzyUp5FoeBLSlhOqQcqc6G05At019kIZowQU783MoSedTbt9OctE9N1yO91DoDzMTWHspdg==", + "dev": true + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "builtin-modules": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz", + "integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "resolve": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", + "integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "rollup": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.31.1.tgz", + "integrity": "sha512-2JREN1YdrS/kpPzEd33ZjtuNbOuBC3ePfuZBdKEybvqcEcszW1ckyVqzcEiEe0nE8sqHK+pbJg+PsAgRJ8+1dg==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/node": "*", + "acorn": "^7.1.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz", + "integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "terser": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.6.3.tgz", + "integrity": "sha512-Lw+ieAXmY69d09IIc/yqeBqXpEQIpDGZqT34ui1QWXIUpR2RjbqEkT8X7Lgex19hslSqcWM5iMN2kM11eMsESQ==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ce08644 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "tabata-timer", + "version": "0.1.0", + "keywords": [], + "author": "Joris Guyonvarch", + "license": "MIT", + "devDependencies": { + "@rollup/plugin-node-resolve": "^7.1.1", + "bs-platform": "^7.0.1", + "bs-webapi": "^0.15.7", + "rollup": "^1.31.0", + "terser": "^4.6.3" + } +} diff --git a/public/icon.png b/public/icon.png Binary files differnew file mode 100644 index 0000000..a489112 --- /dev/null +++ b/public/icon.png diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5668682 --- /dev/null +++ b/public/index.html @@ -0,0 +1,101 @@ +<!DOCTYPE html> +<html lang="fr"> + + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Tabata timer</title> + <link rel="stylesheet" href="main.css" /> + <link rel="icon" href="/icon.png"> + </head> + + <body> + + <header class="g-Layout__Header">Tabata timer</header> + + <main id="g-Layout__Main"> + + <form id="g-Form"> + + <label class="g-Form__Label"> + prepare + <input id="g-Form__Prepare" class="g-Form__Input" type="number" min="0" /> + </label> + + <label class="g-Form__Label"> + tabatas + <input id="g-Form__Tabatas" class="g-Form__Input" type="number" min="1" /> + </label> + + <label class="g-Form__Label"> + cycles + <input id="g-Form__Cycles" class="g-Form__Input" type="number" min="1" /> + </label> + + <label class="g-Form__Label"> + work + <input id="g-Form__Work" class="g-Form__Input" type="number" min="5" /> + </label> + + <label class="g-Form__Label"> + rest + <input id="g-Form__Rest" class="g-Form__Input" type="number" min="5" /> + </label> + + <div class="g-Form__Duration"> + duration + <div id="g-Form__DurationValue">8</div> + </div> + + <button class="g-Form__Start"> + start + </button> + + </form> + + <section id="g-Timer"> + + <button id="g-Timer__Dial"> + + <svg class="g-Timer__Arc" viewbox="-100 -100 200 200"> + <path class="g-Timer__ArcTotal" d="M -1.745121688784978e-14 -95 A 95 95 0 1 0 5.8170722959499274e-15 -95"></path> + <path id="g-Timer__ArcProgress"></path> + </svg> + + <div id="g-Timer__Step"></div> + + <div id="g-Timer__Duration"></div> + + </button> + + <div class="g-Timer__TabataAndCycle"> + + <div class="g-Timer__Tabata"> + <div>Tabata</div> + <span id="g-Timer__TabataCurrent"></span> + / + <span id="g-Timer__TabataTotal"></span> + </div> + + <div class="g-Timer__Cycle"> + <div>Cycle</div> + <span id="g-Timer__CycleCurrent"></span> + / + <span id="g-Timer__CycleTotal"></span> + </div> + + </div> + + <button id="g-Timer__Stop"> + stop + </button> + + </section> + + </main> + + <script src="main.js"></script> + + </body> + +</html> diff --git a/public/main.css b/public/main.css new file mode 100644 index 0000000..9421a3c --- /dev/null +++ b/public/main.css @@ -0,0 +1,175 @@ +/* Box sizing */ + +html { + box-sizing: border-box; +} +*, *:before, *:after { + box-sizing: inherit; +} + +/* Constants */ + +:root { + --color-header: #333333; + --color-action: #993333; + --color-action-darker: #773333; + --color-timer-arc-total: #EEEEEE; + --color-timer-arc-progress: #71b571; + --color-timer-hover: #DDEEDD; + + --dial-width: 20rem; + + --base-font-size: 18px; +} + +@media all and (max-width: 800px) { + :root { + --base-font-size: 14px; + } +} + +/* Layout */ + +html { + font-size: var(--base-font-size); +} + +body { + margin: 0; +} + +.g-Layout__Header { + background-color: var(--color-header); + color: white; + padding: 1rem 2rem; + font-size: 2rem; +} + +#g-Layout__Main { + transition: all 0.2s ease-in-out; +} + +.g-Layout__HideMain { + opacity: 0; + padding-left: 2rem; +} + +/* Config */ + +#g-Form { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 5rem; +} + +.g-Form__Label { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 1rem; + text-align: center; + font-size: 1.3rem; +} + +.g-Form__Input { + display: block; + text-align: center; + margin-top: 0.5rem; + font-size: 1.3rem; + width: 10rem; +} + +.g-Form__Duration { + text-align: center; + font-size: 1.5rem; + margin-top: 1rem; +} + +.g-Form__Start { + font-size: 1.5rem; + background-color: var(--color-action); + border: 3px solid var(--color-action-darker); + color: white; + padding: 0.5rem 0.8rem; + width: 10rem; + margin-top: 2rem; + cursor: pointer; +} + +/* Timer */ + +#g-Timer { + display: flex; + flex-direction: column; + align-items: center; + display: none; +} + +#g-Timer__Dial { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + width: var(--dial-width); + height: var(--dial-width); + cursor: pointer; + background-color: white; + font-size: 3rem; + margin: 5rem 0; + position: relative; + border: none; +} + +.g-Timer__Arc { + width: inherit; + height: inherit; + position: absolute; + top: 0; + left: 0; +} + +.g-Timer__ArcTotal { + stroke: var(--color-timer-arc-total); + fill: none; + stroke-width: 10; +} + +#g-Timer__ArcProgress { + stroke: var(--color-timer-arc-progress); + fill: none; + stroke-width: 10; +} + +#g-Timer__Step { + margin-bottom: 0.5rem; +} + +#g-Timer__Duration:hover { + background-color: var(--color-timer-hover); + color: initial; +} + +.g-Timer__TabataAndCycle { + display: flex; + justify-content: space-around; + font-size: 1.5rem; + width: var(--dial-width); +} + +.g-Timer__Tabata, +.g-Timer__Cycle { + text-align: center; + margin-bottom: 1rem; +} + +#g-Timer__Stop { + font-size: 1.5rem; + background-color: var(--color-action); + border: 3px solid var(--color-action-darker); + color: white; + padding: 0.5rem 0.8rem; + width: 10rem; + margin-top: 2rem; + cursor: pointer; +} diff --git a/public/sounds/c3.mp3 b/public/sounds/c3.mp3 Binary files differnew file mode 100644 index 0000000..13e661a --- /dev/null +++ b/public/sounds/c3.mp3 diff --git a/public/sounds/c4.mp3 b/public/sounds/c4.mp3 Binary files differnew file mode 100644 index 0000000..0266119 --- /dev/null +++ b/public/sounds/c4.mp3 diff --git a/public/sounds/c5.mp3 b/public/sounds/c5.mp3 Binary files differnew file mode 100644 index 0000000..8ff926e --- /dev/null +++ b/public/sounds/c5.mp3 diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..c59bd7b --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,13 @@ +import resolve from '@rollup/plugin-node-resolve'; + +export default { + input: 'src/main.bs.js', + output: { + name: 'tabata_timer', + file: 'public/main.js', + format: 'iife' + }, + plugins: [ + resolve() + ] +}; diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..a2d442a --- /dev/null +++ b/shell.nix @@ -0,0 +1,17 @@ +with import (builtins.fetchTarball { + # https://github.com/NixOS/nixpkgs/commit/77752c6c086512a7c1eb066edcef731696fa2a8e + name = "nixpkgs-20-12-2019"; + url = https://github.com/nixos/nixpkgs/archive/77752c6c086512a7c1eb066edcef731696fa2a8e.tar.gz; + sha256 = "1sb6c9pzq4rjc8sj41qw01b38119xk91pmlxwl80fqhg8mj1ai8r"; +}) {}; + +mkShell { + buildInputs = [ + bs-platform + nodejs + tmux + tmuxinator + ocamlformat + python3 + ]; +} diff --git a/src/Dom/Document.ml b/src/Dom/Document.ml new file mode 100644 index 0000000..afd1a84 --- /dev/null +++ b/src/Dom/Document.ml @@ -0,0 +1,4 @@ +external querySelector : string -> Dom.element option = "querySelector" + [@@bs.val] [@@bs.scope "document"] + +let querySelectorUnsafe id = querySelector id |> Js.Option.getExn diff --git a/src/Dom/Element.ml b/src/Dom/Element.ml new file mode 100644 index 0000000..4b38fa9 --- /dev/null +++ b/src/Dom/Element.ml @@ -0,0 +1,14 @@ +external setValue : Dom.element -> string -> unit = "value" [@@bs.set] + +external setInnerText : Dom.element -> string -> unit = "innerText" [@@bs.set] + +external setStyle : Dom.element -> string -> unit = "style" [@@bs.set] + +external setClassName : Dom.element -> string -> unit = "className" [@@bs.set] + +external setAttribute : Dom.element -> string -> string -> unit = "setAttribute" + [@@bs.send] + +external addEventListener : Dom.element -> string -> (Dom.event -> unit) -> unit + = "addEventListener" + [@@bs.send] diff --git a/src/Dom/Event.ml b/src/Dom/Event.ml new file mode 100644 index 0000000..bffd242 --- /dev/null +++ b/src/Dom/Event.ml @@ -0,0 +1,3 @@ +external preventDefault : Dom.event -> unit = "preventDefault" [@@bs.send] + +external target : Dom.event -> Dom.eventTarget = "target" [@@bs.get] diff --git a/src/Dom/EventTarget.ml b/src/Dom/EventTarget.ml new file mode 100644 index 0000000..946a518 --- /dev/null +++ b/src/Dom/EventTarget.ml @@ -0,0 +1 @@ +external value : Dom.eventTarget -> string option = "value" [@@bs.get] diff --git a/src/animation.ml b/src/animation.ml new file mode 100644 index 0000000..7a598e5 --- /dev/null +++ b/src/animation.ml @@ -0,0 +1,26 @@ +let mainElt = Document.querySelectorUnsafe "#g-Layout__Main" + +let isRunning = ref false + +let start ~onHidden ~onEnded = + if not !isRunning then + let () = isRunning := true in + let () = Element.setClassName mainElt "g-Layout__HideMain" in + let delay = 200 in + let _ = + Js.Global.setTimeout + (fun () -> + let () = onHidden () in + let () = Element.setClassName mainElt "" in + let _ = + Js.Global.setTimeout + (fun () -> + let () = onEnded () in + isRunning := false) + delay + in + ()) + delay + in + () + else () diff --git a/src/arc.ml b/src/arc.ml new file mode 100644 index 0000000..7a3195d --- /dev/null +++ b/src/arc.ml @@ -0,0 +1,23 @@ +let polarToCartesian centerX centerY radius angleInDegrees = + let angleInRadians = (angleInDegrees -. 90.0) *. Js.Math._PI /. 180.0 in + ( centerX +. (radius *. Js.Math.cos angleInRadians), + centerY +. (radius *. Js.Math.sin angleInRadians) ) + +let describe x y radius startAngle endAngle = + let startX, startY = polarToCartesian x y radius endAngle in + let endX, endY = polarToCartesian x y radius startAngle in + let largeArcFlag = if endAngle -. startAngle <= 180.0 then "0" else "1" in + [| + "M"; + Js.Float.toString startX; + Js.Float.toString startY; + "A"; + Js.Float.toString radius; + Js.Float.toString radius; + "0"; + largeArcFlag; + "0"; + Js.Float.toString endX; + Js.Float.toString endY; + |] + |> Js.Array.joinWith " " diff --git a/src/audio.ml b/src/audio.ml new file mode 100644 index 0000000..f7358a7 --- /dev/null +++ b/src/audio.ml @@ -0,0 +1,13 @@ +type audio + +external create : string -> audio = "Audio" [@@bs.new] + +external play : audio -> unit = "play" [@@bs.send] + +external currentTime : audio -> int = "currentTime" [@@bs.get] + +external setCurrentTime : audio -> int -> unit = "currentTime" [@@bs.set] + +let playOrReplay audio = + let () = if currentTime audio > 0 then setCurrentTime audio 0 else () in + play audio diff --git a/src/config.ml b/src/config.ml new file mode 100644 index 0000000..cc98c38 --- /dev/null +++ b/src/config.ml @@ -0,0 +1,96 @@ +(* Model *) + +type config = { + prepare : int; + tabatas : int; + cycles : int; + work : int; + rest : int; +} + +(* State *) + +(* let config = ref { prepare = 10; tabatas = 4; cycles = 8; work = 20; rest = 10 } *) + +let config = ref { prepare = 5; tabatas = 4; cycles = 8; work = 20; rest = 10 } + +let onStart : (unit -> unit) ref = ref (fun () -> ()) + +(* Elements *) + +let formElt = Document.querySelectorUnsafe "#g-Form" + +let prepareElt = Document.querySelectorUnsafe "#g-Form__Prepare" + +let tabatasElt = Document.querySelectorUnsafe "#g-Form__Tabatas" + +let cyclesElt = Document.querySelectorUnsafe "#g-Form__Cycles" + +let workElt = Document.querySelectorUnsafe "#g-Form__Work" + +let restElt = Document.querySelectorUnsafe "#g-Form__Rest" + +let durationElt = Document.querySelectorUnsafe "#g-Form__DurationValue" + +(* Duration *) + +let getDuration () = + let { prepare; tabatas; cycles; work; rest } = !config in + tabatas * (prepare + (cycles * (work + rest))) + +let writeDuration () = + let duration = getDuration () in + Element.setInnerText durationElt (Duration.prettyPrint duration) + +(* Write to DOM *) + +let writeToDom () = + let () = Element.setValue prepareElt (Js.Int.toString !config.prepare) in + let () = Element.setValue tabatasElt (Js.Int.toString !config.tabatas) in + let () = Element.setValue cyclesElt (Js.Int.toString !config.cycles) in + let () = Element.setValue workElt (Js.Int.toString !config.work) in + let () = Element.setValue restElt (Js.Int.toString !config.rest) in + writeDuration () + +(* Update from DOM *) + +let listenTo inputElt update = + Element.addEventListener inputElt "input" (fun e -> + match + EventTarget.value (Event.target e) |> Option.flatMap Belt.Int.fromString + with + | Some n -> + let () = config := update !config n in + writeDuration () + | None -> ()) + +let listenToChanges () = + let () = listenTo prepareElt (fun config n -> { config with prepare = n }) in + let () = listenTo tabatasElt (fun config n -> { config with tabatas = n }) in + let () = listenTo cyclesElt (fun config n -> { config with cycles = n }) in + let () = listenTo workElt (fun config n -> { config with work = n }) in + listenTo restElt (fun config n -> { config with rest = n }) + +(* Setup *) + +let setup onTimerStart = + let () = onStart := onTimerStart in + let () = writeToDom () in + listenToChanges () + +(* Start *) + +let startTimer () = + let () = Element.setStyle formElt "display: none" in + !onStart () + +(* Hide / show *) + +let show () = Element.setStyle formElt "display: flex" + +let hide () = Element.setStyle formElt "display: none" + +let () = + Element.addEventListener formElt "submit" (fun e -> + let () = Event.preventDefault e in + !onStart ()) diff --git a/src/duration.ml b/src/duration.ml new file mode 100644 index 0000000..b0b119b --- /dev/null +++ b/src/duration.ml @@ -0,0 +1,6 @@ +let prettyPrintNumber number = String.padStart (Js.Int.toString number) 2 "0" + +let prettyPrint duration = + let minutes = duration / 60 in + let seconds = duration mod 60 in + prettyPrintNumber minutes ^ ":" ^ prettyPrintNumber seconds diff --git a/src/main.ml b/src/main.ml new file mode 100644 index 0000000..e399e3b --- /dev/null +++ b/src/main.ml @@ -0,0 +1,18 @@ +let onTimerStart () = + Animation.start + ~onHidden:(fun () -> + let () = Config.hide () in + let () = Timer.init () in + Timer.show ()) + ~onEnded:Timer.start + +let onTimerStop () = + Animation.start + ~onHidden:(fun () -> + let () = Timer.hide () in + Config.show ()) + ~onEnded:(fun () -> ()) + +let () = + let () = Config.setup onTimerStart in + Timer.setup onTimerStop diff --git a/src/option.ml b/src/option.ml new file mode 100644 index 0000000..16047fd --- /dev/null +++ b/src/option.ml @@ -0,0 +1 @@ +let flatMap f opt = Belt.Option.flatMapU opt (fun [@bs] x -> f x) diff --git a/src/step.ml b/src/step.ml new file mode 100644 index 0000000..02a110e --- /dev/null +++ b/src/step.ml @@ -0,0 +1,40 @@ +type step = Prepare | Work | Rest | End + +let prettyPrint step = + match step with + | Prepare -> "Prepare" + | Work -> "Work" + | Rest -> "Rest" + | End -> "End" + +type state = { step : step; remaining : int; tabata : int; cycle : int } + +let getAt (config : Config.config) elapsed = + let cycleDuration = config.work + config.rest in + let tabataDuration = config.prepare + (config.cycles * cycleDuration) in + if elapsed >= tabataDuration * config.tabatas then + { + step = End; + remaining = 0; + tabata = config.tabatas; + cycle = config.cycles; + } + else + let currentTabataElapsed = elapsed mod tabataDuration in + let step, remaining = + if currentTabataElapsed < config.prepare then + (Prepare, config.prepare - currentTabataElapsed) + else + let currentCycleElapsed = + (currentTabataElapsed - config.prepare) mod cycleDuration + in + if currentCycleElapsed < config.work then + (Work, config.work - currentCycleElapsed) + else (Rest, config.work + config.rest - currentCycleElapsed) + in + let tabata = (elapsed / tabataDuration) + 1 in + let cycle = + if currentTabataElapsed < config.prepare then 1 + else ((currentTabataElapsed - config.prepare) / cycleDuration) + 1 + in + { step; remaining; tabata; cycle } diff --git a/src/string.ml b/src/string.ml new file mode 100644 index 0000000..335fdec --- /dev/null +++ b/src/string.ml @@ -0,0 +1 @@ +external padStart : string -> int -> string -> string = "padStart" [@@bs.send] diff --git a/src/timer.ml b/src/timer.ml new file mode 100644 index 0000000..5ff0b8b --- /dev/null +++ b/src/timer.ml @@ -0,0 +1,116 @@ +(* Audio *) + +let c3 = Audio.create "sounds/c3.mp3" + +let c4 = Audio.create "sounds/c4.mp3" + +let c5 = Audio.create "sounds/c5.mp3" + +let playAudio (step : Step.state) = + match step.step with + | Step.Prepare when step.remaining == !Config.config.prepare -> + Audio.playOrReplay c3 + | Step.Work when step.remaining == !Config.config.work -> + Audio.playOrReplay c5 + | Step.Rest when step.remaining == !Config.config.rest -> + Audio.playOrReplay c3 + | Step.End -> Audio.playOrReplay c3 + | _ -> if step.remaining <= 3 then Audio.playOrReplay c4 else () + +(* Elements *) + +let timerElt = Document.querySelectorUnsafe "#g-Timer" + +let dialElt = Document.querySelectorUnsafe "#g-Timer__Dial" + +let arcPathElt = Document.querySelectorUnsafe "#g-Timer__ArcProgress" + +let stepElt = Document.querySelectorUnsafe "#g-Timer__Step" + +let durationElt = Document.querySelectorUnsafe "#g-Timer__Duration" + +let tabataCurrentElt = Document.querySelectorUnsafe "#g-Timer__TabataCurrent" + +let tabataTotalElt = Document.querySelectorUnsafe "#g-Timer__TabataTotal" + +let cycleCurrentElt = Document.querySelectorUnsafe "#g-Timer__CycleCurrent" + +let cycleTotalElt = Document.querySelectorUnsafe "#g-Timer__CycleTotal" + +let stopElt = Document.querySelectorUnsafe "#g-Timer__Stop" + +(* State *) + +let interval = ref None + +let duration = ref 0 + +let elapsedTime = ref 0 + +let onStop : (unit -> unit) ref = ref (fun () -> ()) + +let isPlaying = ref false + +(* Actions *) + +let playPause _ = isPlaying := not !isPlaying + +let stop _ = + let () = Belt.Option.forEach !interval Js.Global.clearInterval in + !onStop () + +(* View *) + +let updateDom () = + let angle = + Js.Int.toFloat !elapsedTime /. Js.Int.toFloat !duration *. 360.0 + in + let () = + Element.setAttribute arcPathElt "d" (Arc.describe 0.0 0.0 95.0 0.0 angle) + in + let step = Step.getAt !Config.config !elapsedTime in + let () = Element.setInnerText stepElt (Step.prettyPrint step.step) in + let () = + Element.setInnerText durationElt (Duration.prettyPrint step.remaining) + in + let () = + Element.setInnerText tabataCurrentElt (Js.Int.toString step.tabata) + in + let () = playAudio step in + Element.setInnerText cycleCurrentElt (Js.Int.toString step.cycle) + +(* Update *) + +let update () = + if !isPlaying then + let () = elapsedTime := !elapsedTime + 1 in + if !elapsedTime > !duration then stop () else updateDom () + else () + +(* Init *) + +let init () = + let () = duration := Config.getDuration () in + let () = elapsedTime := 0 in + let () = + Element.setInnerText tabataTotalElt (Js.Int.toString !Config.config.tabatas) + in + Element.setInnerText cycleTotalElt (Js.Int.toString !Config.config.cycles) + +(* Setup and start *) + +let setup onTimerStop = onStop := onTimerStop + +let show () = + let () = updateDom () in + Element.setStyle timerElt "display: flex" + +let hide () = Element.setStyle timerElt "display: none" + +let start () = + let () = interval := Some (Js.Global.setInterval update 1000) in + isPlaying := true + +let () = + let () = Element.addEventListener stopElt "click" stop in + Element.addEventListener dialElt "click" playPause |