aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2020-02-16 22:45:07 +0100
committerJoris2020-02-17 09:15:11 +0100
commit25afb0bde9b8a2c064135a534231c232a461b341 (patch)
tree5ab25640024238a2f6f2d176e5870178f18b5345
parent0366f8cd49d2db40ea5efc639f6a475ecd97675e (diff)
downloadtabata-25afb0bde9b8a2c064135a534231c232a461b341.tar.gz
tabata-25afb0bde9b8a2c064135a534231c232a461b341.tar.bz2
tabata-25afb0bde9b8a2c064135a534231c232a461b341.zip
Set up a first version of tabata timer
-rw-r--r--.bsb.lock1
-rw-r--r--.gitignore5
-rw-r--r--.gitlab-ci.yml10
-rw-r--r--.ocamlformat1
-rw-r--r--.tmuxinator.yml14
-rw-r--r--LICENSE19
-rw-r--r--Makefile22
-rw-r--r--README.md29
-rw-r--r--bsconfig.json22
-rwxr-xr-xdeploy12
-rwxr-xr-xdev27
-rw-r--r--package-lock.json150
-rw-r--r--package.json14
-rw-r--r--public/icon.pngbin0 -> 886 bytes
-rw-r--r--public/index.html101
-rw-r--r--public/main.css175
-rw-r--r--public/sounds/c3.mp3bin0 -> 47469 bytes
-rw-r--r--public/sounds/c4.mp3bin0 -> 57357 bytes
-rw-r--r--public/sounds/c5.mp3bin0 -> 65742 bytes
-rw-r--r--rollup.config.js13
-rw-r--r--shell.nix17
-rw-r--r--src/Dom/Document.ml4
-rw-r--r--src/Dom/Element.ml14
-rw-r--r--src/Dom/Event.ml3
-rw-r--r--src/Dom/EventTarget.ml1
-rw-r--r--src/animation.ml26
-rw-r--r--src/arc.ml23
-rw-r--r--src/audio.ml13
-rw-r--r--src/config.ml96
-rw-r--r--src/duration.ml6
-rw-r--r--src/main.ml18
-rw-r--r--src/option.ml1
-rw-r--r--src/step.ml40
-rw-r--r--src/string.ml1
-rw-r--r--src/timer.ml116
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
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
--- /dev/null
+++ b/public/icon.png
Binary files 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 @@
+<!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
new file mode 100644
index 0000000..13e661a
--- /dev/null
+++ b/public/sounds/c3.mp3
Binary files differ
diff --git a/public/sounds/c4.mp3 b/public/sounds/c4.mp3
new file mode 100644
index 0000000..0266119
--- /dev/null
+++ b/public/sounds/c4.mp3
Binary files differ
diff --git a/public/sounds/c5.mp3 b/public/sounds/c5.mp3
new file mode 100644
index 0000000..8ff926e
--- /dev/null
+++ b/public/sounds/c5.mp3
Binary files 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