From 25afb0bde9b8a2c064135a534231c232a461b341 Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 16 Feb 2020 22:45:07 +0100 Subject: Set up a first version of tabata timer --- .bsb.lock | 1 + .gitignore | 5 ++ .gitlab-ci.yml | 10 +++ .ocamlformat | 1 + .tmuxinator.yml | 14 ++++ LICENSE | 19 ++++++ Makefile | 22 +++++++ README.md | 29 ++++++++ bsconfig.json | 22 +++++++ deploy | 12 ++++ dev | 27 ++++++++ package-lock.json | 150 ++++++++++++++++++++++++++++++++++++++++++ package.json | 14 ++++ public/icon.png | Bin 0 -> 886 bytes public/index.html | 101 ++++++++++++++++++++++++++++ public/main.css | 175 +++++++++++++++++++++++++++++++++++++++++++++++++ public/sounds/c3.mp3 | Bin 0 -> 47469 bytes public/sounds/c4.mp3 | Bin 0 -> 57357 bytes public/sounds/c5.mp3 | Bin 0 -> 65742 bytes rollup.config.js | 13 ++++ shell.nix | 17 +++++ src/Dom/Document.ml | 4 ++ src/Dom/Element.ml | 14 ++++ src/Dom/Event.ml | 3 + src/Dom/EventTarget.ml | 1 + src/animation.ml | 26 ++++++++ src/arc.ml | 23 +++++++ src/audio.ml | 13 ++++ src/config.ml | 96 +++++++++++++++++++++++++++ src/duration.ml | 6 ++ src/main.ml | 18 +++++ src/option.ml | 1 + src/step.ml | 40 +++++++++++ src/string.ml | 1 + src/timer.ml | 116 ++++++++++++++++++++++++++++++++ 35 files changed, 994 insertions(+) create mode 100644 .bsb.lock create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .ocamlformat create mode 100644 .tmuxinator.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 bsconfig.json create mode 100755 deploy create mode 100755 dev create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/icon.png create mode 100644 public/index.html create mode 100644 public/main.css create mode 100644 public/sounds/c3.mp3 create mode 100644 public/sounds/c4.mp3 create mode 100644 public/sounds/c5.mp3 create mode 100644 rollup.config.js create mode 100644 shell.nix create mode 100644 src/Dom/Document.ml create mode 100644 src/Dom/Element.ml create mode 100644 src/Dom/Event.ml create mode 100644 src/Dom/EventTarget.ml create mode 100644 src/animation.ml create mode 100644 src/arc.ml create mode 100644 src/audio.ml create mode 100644 src/config.ml create mode 100644 src/duration.ml create mode 100644 src/main.ml create mode 100644 src/option.ml create mode 100644 src/step.ml create mode 100644 src/string.ml create mode 100644 src/timer.ml 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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c1e6c0b --- /dev/null +++ b/LICENSE @@ -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 +} diff --git a/deploy b/deploy new file mode 100755 index 0000000..8ceaa8f --- /dev/null +++ b/deploy @@ -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 diff --git a/dev b/dev new file mode 100755 index 0000000..af2b51a --- /dev/null +++ b/dev @@ -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 new file mode 100644 index 0000000..a489112 Binary files /dev/null and b/public/icon.png differ 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 @@ + + + + + + + Tabata timer + + + + + + +
Tabata timer
+ +
+ +
+ + + + + + + + + + + +
+ duration +
8
+
+ + + +
+ +
+ + + +
+ +
+
Tabata
+ + / + +
+ +
+
Cycle
+ + / + +
+ +
+ + + +
+ +
+ + + + + + 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 new file mode 100644 index 0000000..13e661a Binary files /dev/null and b/public/sounds/c3.mp3 differ diff --git a/public/sounds/c4.mp3 b/public/sounds/c4.mp3 new file mode 100644 index 0000000..0266119 Binary files /dev/null and b/public/sounds/c4.mp3 differ diff --git a/public/sounds/c5.mp3 b/public/sounds/c5.mp3 new file mode 100644 index 0000000..8ff926e Binary files /dev/null and b/public/sounds/c5.mp3 differ 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 -- cgit v1.2.3