aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile22
-rw-r--r--README.md30
-rwxr-xr-xbin/watch.sh8
-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/index.html42
-rw-r--r--public/main.css114
-rw-r--r--public/sound/end-tabata.mp3bin0 -> 34516 bytes
-rw-r--r--public/sound/end-training.mp3bin0 -> 54090 bytes
-rw-r--r--public/sound/start.mp3bin0 -> 36013 bytes
-rw-r--r--public/sound/stop.mp3bin0 -> 37958 bytes
-rw-r--r--public/sounds/c3.mp3bin47469 -> 0 bytes
-rw-r--r--public/sounds/c4.mp3bin57357 -> 0 bytes
-rw-r--r--public/sounds/c5.mp3bin65742 -> 0 bytes
-rw-r--r--rollup.config.js13
-rw-r--r--shell.nix21
-rw-r--r--src/Dom/CreateElement.ml72
-rw-r--r--src/Dom/Document.ml14
-rw-r--r--src/Dom/Element.ml44
-rw-r--r--src/Dom/Event.ml3
-rw-r--r--src/Dom/EventTarget.ml4
-rw-r--r--src/Model/config.ml12
-rw-r--r--src/Model/step.ml40
-rw-r--r--src/View/configView.ml83
-rw-r--r--src/View/timerView.ml123
-rw-r--r--src/animation.ml27
-rw-r--r--src/arc.ml23
-rw-r--r--src/arc.ts37
-rw-r--r--src/audio.ml34
-rw-r--r--src/audio.ts19
-rw-r--r--src/config.ts21
-rw-r--r--src/duration.ml6
-rw-r--r--src/duration.ts9
-rw-r--r--src/h.ts30
-rw-r--r--src/main.ml14
-rw-r--r--src/main.ts21
-rw-r--r--src/option.ml1
-rw-r--r--src/router.ts55
-rw-r--r--src/state.ts63
-rw-r--r--src/string.ml1
-rw-r--r--src/view/form.ts47
-rw-r--r--src/view/timer.ts158
-rw-r--r--tsconfig.json13
46 files changed, 599 insertions, 850 deletions
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 6c9a2b3..0000000
--- a/Makefile
+++ /dev/null
@@ -1,22 +0,0 @@
-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
index f1d9c26..9f5b91f 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,9 @@
-# Tabata timer
+# Getting started
-Available at [https://guyonvarch.gitlab.io/tabata-timer](https://guyonvarch.gitlab.io/tabata-timer).
+Run:
-## Gettings started
-
-Start the environment with:
-
-```bash
-./dev start
-```
-
-Later, stop the environment with:
-
-```bash
-./dev stop
+```sh
+nix-shell --run bin/watch.sh
```
-## 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)
+Then, open your browser at `http://localhost:8000`.
diff --git a/bin/watch.sh b/bin/watch.sh
new file mode 100755
index 0000000..86eeab7
--- /dev/null
+++ b/bin/watch.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+python -m http.server --directory public 8000 &
+
+trap "fuser -k 8000/tcp" EXIT
+
+tsc --target ES2017 --watch
diff --git a/bsconfig.json b/bsconfig.json
deleted file mode 100644
index db0c8d9..0000000
--- a/bsconfig.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "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
deleted file mode 100755
index 8ceaa8f..0000000
--- a/deploy
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/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
deleted file mode 100755
index af2b51a..0000000
--- a/dev
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/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
deleted file mode 100644
index f20e082..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,150 +0,0 @@
-{
- "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
deleted file mode 100644
index ce08644..0000000
--- a/package.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "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/index.html b/public/index.html
index 6cf6562..a57c5c2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,21 +1,27 @@
-<!DOCTYPE html>
+<!doctype html>
<html lang="fr">
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width">
+<title>Tabata Timer</title>
+<link rel="stylesheet" href="/main.css">
+<link rel="icon" href="/icon.png">
- <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>
+ <script>
+ // https://github.com/al6x/stupid&#45;simple&#45;typescript&#45;web&#45;starter
+ window.define = function(name, required, moduleFn) {
+ var require = function() { throw new Error("AMD require not supported!")}
+ var exports = window.define.modules[name] = {}
+ var resolved = [require, exports]
+ for (var i = 2; i < required.length; i++) {
+ var m = window.define.modules[required[i]]
+ if (!m) throw new Error("AMD module `" + required[i] + "` not found!")
+ resolved.push(m)
+ }
+ moduleFn.apply(null, resolved)
+ }
+ window.define.modules = {}
+ </script>
- <body>
-
- <main id="g-Layout__Main">
- </main>
-
- <script src="main.js"></script>
-
- </body>
-
-</html>
+ <script src="main.js"></script>
+</body>
diff --git a/public/main.css b/public/main.css
index f1efc26..5df74af 100644
--- a/public/main.css
+++ b/public/main.css
@@ -10,15 +10,17 @@ html {
/* Constants */
:root {
+ --color-active: #F3E87F;
--color-header: #333333;
- --color-action: #993333;
- --color-action-darker: #773333;
- --color-timer-arc-total: #EEEEEE;
- --color-timer-arc-progress: #71b571;
+ --color-action: #333333;
+ --color-action-darker: #222222;
+ --color-prepare: #3B6EDC;
+ --color-pause: #888888;
+ --color-work: #71b571;
+ --color-rest: #B15B5B;
+ --color-timer-arc-total: #222222;
--color-timer-hover: #DDEEDD;
- --dial-width: 20rem;
-
--base-font-size: 18px;
}
@@ -38,6 +40,11 @@ body {
margin: 0;
}
+.g-Layout__Page {
+ height: 100vh;
+ overflow-y: auto;
+}
+
.g-Layout__Header {
background-color: var(--color-header);
color: white;
@@ -113,26 +120,42 @@ body {
/* Timer */
.g-Timer {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- background-color: var(--color-header);
+ display: grid;
+ grid-template-columns: 10% auto 10%;
+ grid-template-rows: 10% auto 10% 10% 10%;
color: white;
}
+.g-Timer--Work {
+ background-color: var(--color-work);
+}
+
+.g-Timer--Rest {
+ background-color: var(--color-rest);
+}
+
+.g-Timer--Prepare {
+ background-color: var(--color-prepare);
+}
+
+.g-Timer__Pause {
+ background-color: var(--color-pause);
+}
+
.g-Timer__Dial {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
- width: var(--dial-width);
- height: var(--dial-width);
- cursor: pointer;
+
+ width: 100%;
+ height: 100%;
+ grid-row: 2;
+ grid-column: 2;
+
background-color: transparent;
color: inherit;
font-size: 3rem;
- margin: 2rem 0;
position: relative;
border: none;
}
@@ -148,45 +171,66 @@ body {
.g-Timer__ArcTotal {
stroke: var(--color-timer-arc-total);
fill: none;
- stroke-width: 10;
+ stroke-width: 20;
}
-.g-Timer__ArcProgress {
- stroke: var(--color-timer-arc-progress);
+.g-Timer__ArcPrepare {
+ stroke: var(--color-prepare);
fill: none;
- stroke-width: 10;
+ stroke-width: 18;
}
-.g-Timer__Step {
- margin-bottom: 0.5rem;
+.g-Timer__ArcWork {
+ stroke: var(--color-work);
+ fill: none;
+ stroke-width: 18;
}
-.g-Timer__Duration:hover {
- background-color: var(--color-timer-hover);
- color: initial;
+.g-Timer__ArcRest {
+ stroke: var(--color-rest);
+ fill: none;
+ stroke-width: 18;
}
-.g-Timer__TabataAndCycle {
- display: flex;
- justify-content: space-around;
- font-size: 1.5rem;
- width: var(--dial-width);
+.g-Timer__ArcProgress {
+ stroke: var(--color-active);
+ fill: none;
+ stroke-width: 18;
}
-.g-Timer__Tabata,
-.g-Timer__Cycle {
- text-align: center;
- margin-bottom: 1rem;
+.g-Timer__Step {
+ margin-bottom: 2rem;
}
-.g-Timer__Stop {
+.g-Timer__Buttons {
+ display: flex;
+ justify-content: space-around;
+ grid-row: 4;
+ grid-column: 2;
+ width: 100%;
+ height: 100%;
+}
+
+.g-Timer__Button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
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;
text-align: center;
+ text-decoration: none;
+}
+
+.g-Timer__Button:not(:last-child) {
+ margin-right: 2rem;
+}
+
+.g-Timer__Button--Active {
+ background-color: var(--color-active);
+ color: black;
}
diff --git a/public/sound/end-tabata.mp3 b/public/sound/end-tabata.mp3
new file mode 100644
index 0000000..860c703
--- /dev/null
+++ b/public/sound/end-tabata.mp3
Binary files differ
diff --git a/public/sound/end-training.mp3 b/public/sound/end-training.mp3
new file mode 100644
index 0000000..8af4c27
--- /dev/null
+++ b/public/sound/end-training.mp3
Binary files differ
diff --git a/public/sound/start.mp3 b/public/sound/start.mp3
new file mode 100644
index 0000000..05aee23
--- /dev/null
+++ b/public/sound/start.mp3
Binary files differ
diff --git a/public/sound/stop.mp3 b/public/sound/stop.mp3
new file mode 100644
index 0000000..c618f28
--- /dev/null
+++ b/public/sound/stop.mp3
Binary files differ
diff --git a/public/sounds/c3.mp3 b/public/sounds/c3.mp3
deleted file mode 100644
index 13e661a..0000000
--- a/public/sounds/c3.mp3
+++ /dev/null
Binary files differ
diff --git a/public/sounds/c4.mp3 b/public/sounds/c4.mp3
deleted file mode 100644
index 0266119..0000000
--- a/public/sounds/c4.mp3
+++ /dev/null
Binary files differ
diff --git a/public/sounds/c5.mp3 b/public/sounds/c5.mp3
deleted file mode 100644
index 8ff926e..0000000
--- a/public/sounds/c5.mp3
+++ /dev/null
Binary files differ
diff --git a/rollup.config.js b/rollup.config.js
deleted file mode 100644
index c59bd7b..0000000
--- a/rollup.config.js
+++ /dev/null
@@ -1,13 +0,0 @@
-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
index a2d442a..ad4a34d 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,17 +1,16 @@
-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";
-}) {};
+with (import (builtins.fetchGit {
+ name = "nixpkgs-20.09";
+ url = "git@github.com:nixos/nixpkgs.git";
+ rev = "cd63096d6d887d689543a0b97743d28995bc9bc3";
+ ref = "refs/tags/20.09";
+}){});
mkShell {
+
buildInputs = [
- bs-platform
- nodejs
- tmux
- tmuxinator
- ocamlformat
+ nodePackages.typescript
python3
+ psmisc # fuser
];
+
}
diff --git a/src/Dom/CreateElement.ml b/src/Dom/CreateElement.ml
deleted file mode 100644
index 8183a02..0000000
--- a/src/Dom/CreateElement.ml
+++ /dev/null
@@ -1,72 +0,0 @@
-(* Element creation *)
-
-let h tag ?(attributes = [||]) ?(eventListeners = [||]) ?(children = [||]) () :
- Dom.element =
- let element =
- if tag == "svg" || tag == "path" then
- Document.createElementNS "http://www.w3.org/2000/svg" tag
- else Document.createElement tag
- in
- let () =
- Js.Array.forEach
- (fun (name, value) -> Element.setAttribute element name value)
- attributes
- in
- let () =
- Js.Array.forEach
- (fun (name, eventListener) ->
- Element.addEventListener element name eventListener)
- eventListeners
- in
- let () =
- Js.Array.forEach (fun child -> Element.appendChild element child) children
- in
- element
-
-(* Node creation *)
-
-let text = Document.createTextNode
-
-let div = h "div"
-
-let span = h "span"
-
-let header = h "header"
-
-let button = h "button"
-
-let section = h "section"
-
-let svg = h "svg"
-
-let path = h "path"
-
-let form = h "form"
-
-let label = h "label"
-
-let input_ = h "input"
-
-(* Attribute creation *)
-
-let id v = ("id", v)
-
-let className v = ("class", v)
-
-let viewBox v = ("viewBox", v)
-
-let d v = ("d", v)
-
-let type_ v = ("type", v)
-
-let min_ v = ("min", v)
-
-let value v = ("value", v)
-
-(* Event listeners *)
-
-let onClick f = ("click", f)
-
-let onInput f = ("input", f)
-
-let onSubmit f = ("submit", f)
diff --git a/src/Dom/Document.ml b/src/Dom/Document.ml
deleted file mode 100644
index 867e28c..0000000
--- a/src/Dom/Document.ml
+++ /dev/null
@@ -1,14 +0,0 @@
-external createElement : string -> Dom.element = "createElement"
- [@@bs.val] [@@bs.scope "document"]
-
-external createElementNS : string -> string -> Dom.element = "createElementNS"
- [@@bs.val] [@@bs.scope "document"]
-
-external querySelector : string -> Dom.element Js.Nullable.t = "querySelector"
- [@@bs.val] [@@bs.scope "document"]
-
-let querySelectorUnsafe id =
- querySelector id |> Js.Nullable.toOption |> Js.Option.getExn
-
-external createTextNode : string -> Dom.element = "createTextNode"
- [@@bs.val] [@@bs.scope "document"]
diff --git a/src/Dom/Element.ml b/src/Dom/Element.ml
deleted file mode 100644
index 0b6c0bd..0000000
--- a/src/Dom/Element.ml
+++ /dev/null
@@ -1,44 +0,0 @@
-external setValue : Dom.element -> string -> unit = "value" [@@bs.set]
-
-external setTextContent : Dom.element -> string -> unit = "textContent"
- [@@bs.set]
-
-external setStyle : Dom.element -> string -> unit = "style" [@@bs.set]
-
-external setClassName : Dom.element -> string -> unit = "className" [@@bs.set]
-
-external setScrollTop : Dom.element -> int -> unit = "scrollTop" [@@bs.set]
-
-external setAttribute : Dom.element -> string -> string -> unit = "setAttribute"
- [@@bs.send]
-
-external setAttributeNS : Dom.element -> string -> string -> string -> unit
- = "setAttributeNS"
- [@@bs.send]
-
-external addEventListener : Dom.element -> string -> (Dom.event -> unit) -> unit
- = "addEventListener"
- [@@bs.send]
-
-external appendChild : Dom.element -> Dom.element -> unit = "appendChild"
- [@@bs.send]
-
-external firstChild : Dom.element -> Dom.element Js.Nullable.t = "firstChild"
- [@@bs.get]
-
-external removeChild : Dom.element -> Dom.element -> unit = "removeChild"
- [@@bs.send]
-
-let removeFirstChild element =
- match Js.toOption (firstChild element) with
- | Some child ->
- let () = removeChild element child in
- true
- | _ -> false
-
-let rec removeChildren element =
- if removeFirstChild element then removeChildren element else ()
-
-let mountOn base element =
- let () = removeChildren base in
- appendChild base element
diff --git a/src/Dom/Event.ml b/src/Dom/Event.ml
deleted file mode 100644
index bffd242..0000000
--- a/src/Dom/Event.ml
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index d1b0c02..0000000
--- a/src/Dom/EventTarget.ml
+++ /dev/null
@@ -1,4 +0,0 @@
-external nullableValue : Dom.eventTarget -> string Js.Nullable.t = "value"
- [@@bs.get]
-
-let value eventTarget = nullableValue eventTarget |> Js.Nullable.toOption
diff --git a/src/Model/config.ml b/src/Model/config.ml
deleted file mode 100644
index 99e42d1..0000000
--- a/src/Model/config.ml
+++ /dev/null
@@ -1,12 +0,0 @@
-type config = {
- prepare : int;
- tabatas : int;
- cycles : int;
- work : int;
- rest : int;
-}
-
-let init = { prepare = 10; tabatas = 4; cycles = 8; work = 20; rest = 10 }
-
-let getDuration { prepare; tabatas; cycles; work; rest } =
- tabatas * (prepare + (cycles * (work + rest)))
diff --git a/src/Model/step.ml b/src/Model/step.ml
deleted file mode 100644
index 02a110e..0000000
--- a/src/Model/step.ml
+++ /dev/null
@@ -1,40 +0,0 @@
-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/View/configView.ml b/src/View/configView.ml
deleted file mode 100644
index 5db6ea5..0000000
--- a/src/View/configView.ml
+++ /dev/null
@@ -1,83 +0,0 @@
-open CreateElement
-open Config
-
-let labelledInput labelValue minValue inputValue update writeDuration =
- label
- ~attributes:[| className "g-Form__Label" |]
- ~eventListeners:
- [|
- onInput (fun e ->
- match
- EventTarget.value (Event.target e)
- |> Option.flatMap Belt.Int.fromString
- with
- | Some n ->
- let () = update n in
- writeDuration ()
- | None -> ());
- |]
- ~children:
- [|
- text labelValue;
- input_
- ~attributes:
- [|
- className "g-Form__Input";
- type_ "number";
- min_ (Js.Int.toString minValue);
- value (Js.Int.toString inputValue);
- |]
- ();
- |]
- ()
-
-let render initialConfig onStart =
- let config = ref initialConfig in
- let duration = text (Duration.prettyPrint (getDuration !config)) in
- let wd () =
- Element.setTextContent duration (Duration.prettyPrint (getDuration !config))
- in
- div
- ~children:
- [|
- header
- ~attributes:[| className "g-Layout__Header" |]
- ~children:[| text "Tabata timer" |]
- ();
- form
- ~attributes:[| className "g-Form" |]
- ~eventListeners:
- [|
- onSubmit (fun e ->
- let () = Event.preventDefault e in
- onStart !config);
- |]
- ~children:
- [|
- labelledInput "prepare" 0 !config.prepare
- (fun n -> config := { !config with prepare = n })
- wd;
- labelledInput "tabatas" 1 !config.tabatas
- (fun n -> config := { !config with tabatas = n })
- wd;
- labelledInput "cycles" 1 !config.cycles
- (fun n -> config := { !config with cycles = n })
- wd;
- labelledInput "work" 5 !config.work
- (fun n -> config := { !config with work = n })
- wd;
- labelledInput "rest" 5 !config.rest
- (fun n -> config := { !config with rest = n })
- wd;
- div
- ~attributes:[| className "g-Form__Duration" |]
- ~children:[| text "duration"; div ~children:[| duration |] () |]
- ();
- button
- ~attributes:[| className "g-Form__Start" |]
- ~children:[| text "start" |]
- ();
- |]
- ();
- |]
- ()
diff --git a/src/View/timerView.ml b/src/View/timerView.ml
deleted file mode 100644
index 2384f85..0000000
--- a/src/View/timerView.ml
+++ /dev/null
@@ -1,123 +0,0 @@
-open CreateElement
-
-let render (config : Config.config) onStop =
- let duration = Config.getDuration config in
- (* State *)
- let interval = ref None in
- let elapsed = ref 0 in
- let step = ref (Step.getAt config !elapsed) in
- let isPlaying = ref true in
- (* Elements *)
- let stepElt = text (Step.prettyPrint !step.step) in
- let durationElt = text (Duration.prettyPrint !step.remaining) in
- let arcPathElt = path ~attributes:[| className "g-Timer__ArcProgress" |] () in
- let tabataCurrentElt = text (Js.Int.toString !step.tabata) in
- let cycleCurrentElt = text (Js.Int.toString !step.cycle) in
- (* Update *)
- let stop () =
- let () = Belt.Option.forEach !interval Js.Global.clearInterval in
- onStop config
- in
- let updateDom () =
- let angle = Js.Int.toFloat !elapsed /. 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 !elapsed in
- let () = Element.setTextContent stepElt (Step.prettyPrint step.step) in
- let () =
- Element.setTextContent durationElt (Duration.prettyPrint step.remaining)
- in
- let () =
- Element.setTextContent tabataCurrentElt (Js.Int.toString step.tabata)
- in
- let () =
- Element.setTextContent cycleCurrentElt (Js.Int.toString step.cycle)
- in
- Audio.playFromStep config step
- in
- let update () =
- if !isPlaying then
- let () = elapsed := !elapsed + 1 in
- let () = step := Step.getAt config !elapsed in
- if !elapsed > duration then stop () else updateDom ()
- else ()
- in
- (* Start timer *)
- let () = interval := Some (Js.Global.setInterval update 1000) in
- (* View *)
- section
- ~attributes:[| className "g-Timer" |]
- ~children:
- [|
- button
- ~attributes:[| className "g-Timer__Dial" |]
- ~eventListeners:[| onClick (fun _ -> isPlaying := not !isPlaying) |]
- ~children:
- [|
- svg
- ~attributes:
- [| className "g-Timer__Arc"; viewBox "-100 -100 200 200" |]
- ~children:
- [|
- path
- ~attributes:
- [|
- className "g-Timer__ArcTotal";
- d (Arc.describe 0.0 0.0 95.0 0.0 359.999);
- |]
- ();
- arcPathElt;
- |]
- ();
- div
- ~attributes:[| className "g-Timer__Step" |]
- ~children:[| stepElt |] ();
- div
- ~attributes:[| className "g-Timer__Duration" |]
- ~children:[| durationElt |] ();
- |]
- ();
- div
- ~attributes:[| className "g-Timer__TabataAndCycle" |]
- ~children:
- [|
- div
- ~attributes:[| className "g-Timer__Tabata" |]
- ~children:
- [|
- div ~children:[| text "Tabata" |] ();
- span
- ~attributes:[| className "g-Timer__TabataCurrent" |]
- ~children:[| tabataCurrentElt |] ();
- text "/";
- span
- ~attributes:[| className "g-Timer__TabataTotal" |]
- ~children:[| text (Js.Int.toString config.tabatas) |]
- ();
- |]
- ();
- div
- ~attributes:[| className "g-Timer__Cycle" |]
- ~children:
- [|
- div ~children:[| text "Cycle" |] ();
- span
- ~attributes:[| className "g-Timer__CycleCurrent" |]
- ~children:[| cycleCurrentElt |] ();
- text "/";
- span
- ~attributes:[| className "g-Timer__CycleTotal" |]
- ~children:[| text (Js.Int.toString config.cycles) |]
- ();
- |]
- ();
- |]
- ();
- div
- ~attributes:[| className "g-Timer__Stop" |]
- ~children:[| text "stop" |]
- ~eventListeners:[| onClick (fun _ -> stop ()) |]
- ();
- |]
- ()
diff --git a/src/animation.ml b/src/animation.ml
deleted file mode 100644
index 35294dc..0000000
--- a/src/animation.ml
+++ /dev/null
@@ -1,27 +0,0 @@
-let isRunning = ref false
-
-let start base ~onStart ~onEnd =
- if not !isRunning then
- let () = isRunning := true in
- let () = onStart () in
- let () = Element.setClassName base "g-Animation" in
- let delay = 400 in
- let _ =
- Js.Global.setTimeout
- (fun () ->
- let () = Element.setClassName base "" in
- let () = onEnd () in
- isRunning := false)
- delay
- in
- ()
- else ()
-
-let replaceChild scrollBase base mkChild =
- start base
- ~onStart:(fun _ ->
- let () = Element.setScrollTop scrollBase 0 in
- Element.appendChild base (mkChild ()))
- ~onEnd:(fun _ ->
- let _ = Element.removeFirstChild base in
- ())
diff --git a/src/arc.ml b/src/arc.ml
deleted file mode 100644
index 7a3195d..0000000
--- a/src/arc.ml
+++ /dev/null
@@ -1,23 +0,0 @@
-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/arc.ts b/src/arc.ts
new file mode 100644
index 0000000..d8e1e7d
--- /dev/null
+++ b/src/arc.ts
@@ -0,0 +1,37 @@
+function polarToCartesian(
+ centerX: number,
+ centerY: number,
+ radius: number,
+ degreesAngle: number
+): number[] {
+ const radianAngle = (degreesAngle - 90) * Math.PI / 180
+ return [
+ centerX + radius * Math.cos(radianAngle),
+ centerY + radius * Math.sin(radianAngle)
+ ]
+}
+
+export function describe(
+ x: number,
+ y: number,
+ radius: number,
+ startAngle: number,
+ endAngle: number
+): string {
+ const [startX, startY] = polarToCartesian(x, y, radius, endAngle)
+ const [endX, endY] = polarToCartesian(x, y, radius, startAngle)
+ const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1"
+ return [
+ "M",
+ startX.toString(),
+ startY,
+ "A",
+ radius.toString(),
+ radius.toString(),
+ "0",
+ largeArcFlag,
+ "0",
+ endX.toString(),
+ endY.toString(),
+ ].join(" ")
+}
diff --git a/src/audio.ml b/src/audio.ml
deleted file mode 100644
index 1446440..0000000
--- a/src/audio.ml
+++ /dev/null
@@ -1,34 +0,0 @@
-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
-
-(* Sounds *)
-
-let c3 = create "sounds/c3.mp3"
-
-let c4 = create "sounds/c4.mp3"
-
-let c5 = create "sounds/c5.mp3"
-
-(* Play from step *)
-
-let playFromStep (config: Config.config) (step : Step.state) =
- match step.step with
- | Step.Prepare when step.remaining == config.prepare ->
- playOrReplay c3
- | Step.Work when step.remaining == config.work ->
- playOrReplay c5
- | Step.Rest when step.remaining == config.rest ->
- playOrReplay c3
- | Step.End -> playOrReplay c3
- | _ -> if step.remaining <= 3 then playOrReplay c4 else ()
diff --git a/src/audio.ts b/src/audio.ts
new file mode 100644
index 0000000..bdf64eb
--- /dev/null
+++ b/src/audio.ts
@@ -0,0 +1,19 @@
+import * as Config from 'config'
+import * as State from 'state'
+
+const start = new Audio('sound/start.mp3')
+const stop = new Audio('sound/stop.mp3')
+const endTabata = new Audio('sound/end-tabata.mp3')
+const endTraining = new Audio('sound/end-training.mp3')
+
+export function playFromStep(config: Config.Config, state: State.State) {
+ if (state.step === State.Step.Work && state.remaining === config.work) {
+ start.play()
+ } else if (state.step === State.Step.Rest && state.remaining === config.rest) {
+ stop.play()
+ } else if (state.step === State.Step.Prepare && state.remaining === config.prepare) {
+ endTabata.play()
+ } else if (state.step === State.Step.End) {
+ endTraining.play()
+ }
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..c20bff2
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,21 @@
+export interface Config {
+ prepare : number;
+ tabatas : number;
+ cycles : number;
+ work : number;
+ rest : number;
+}
+
+export function init(): Config {
+ return {
+ prepare: 10,
+ tabatas: 4,
+ cycles: 8,
+ work: 20,
+ rest: 10
+ }
+}
+
+export function getDuration(c: Config): number {
+ return c.tabatas * (c.prepare + (c.cycles * (c.work + c.rest)))
+}
diff --git a/src/duration.ml b/src/duration.ml
deleted file mode 100644
index b0b119b..0000000
--- a/src/duration.ml
+++ /dev/null
@@ -1,6 +0,0 @@
-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/duration.ts b/src/duration.ts
new file mode 100644
index 0000000..92952a7
--- /dev/null
+++ b/src/duration.ts
@@ -0,0 +1,9 @@
+function prettyPrintNumber(n: number): string {
+ return n.toString().padStart(2, "0")
+}
+
+export function prettyPrint(duration: number): string {
+ const minutes = Math.floor(duration / 60)
+ const seconds = duration % 60
+ return prettyPrintNumber(minutes) + ":" + prettyPrintNumber(seconds)
+}
diff --git a/src/h.ts b/src/h.ts
new file mode 100644
index 0000000..bb21efd
--- /dev/null
+++ b/src/h.ts
@@ -0,0 +1,30 @@
+type Child = Element | Text | string | number
+
+export default function h(
+ tagName: string,
+ attrs: object,
+ ...children: Child[]
+): Element {
+ const isSvg = tagName === 'svg' || tagName === 'path'
+
+ let elem = isSvg
+ ? document.createElementNS('http://www.w3.org/2000/svg', tagName)
+ : document.createElement(tagName)
+
+ if (isSvg) {
+ Object.entries(attrs).forEach(([key, value]) => {
+ elem.setAttribute(key, value)
+ })
+ } else {
+ elem = Object.assign(elem, attrs)
+ }
+
+ for (const child of children) {
+ if (typeof child === 'number')
+ elem.append(child.toString())
+ else
+ elem.append(child)
+ }
+
+ return elem
+}
diff --git a/src/main.ml b/src/main.ml
deleted file mode 100644
index 003880b..0000000
--- a/src/main.ml
+++ /dev/null
@@ -1,14 +0,0 @@
-type view = Config of Config.config | Timer of Config.config
-
-let () =
- let html = Document.querySelectorUnsafe "html" in
- let main = Document.querySelectorUnsafe "main" in
- let rec showView v =
- Animation.replaceChild html main (fun _ ->
- match v with
- | Config config ->
- ConfigView.render config (fun config -> showView (Timer config))
- | Timer config ->
- TimerView.render config (fun config -> showView (Config config)))
- in
- showView (Config Config.init)
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..436a217
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,21 @@
+import * as Config from 'config'
+import * as Form from 'view/form'
+import * as Timer from 'view/timer'
+import * as Router from 'router'
+
+export function showPage(route: Router.Route) {
+ if (route.kind === Router.Kind.Form) {
+ document.body.innerHTML = ''
+ document.body.appendChild(Form.view(route.config, showPage))
+ } else if (route.kind === Router.Kind.Timer) {
+ document.body.innerHTML = ''
+ document.body.appendChild(Timer.view(route.config, showPage))
+ }
+}
+
+showPage(Router.from(document.location))
+
+window.onpopstate = (event: Event) => {
+ Timer.clearInterval()
+ showPage(Router.from(document.location))
+}
diff --git a/src/option.ml b/src/option.ml
deleted file mode 100644
index 16047fd..0000000
--- a/src/option.ml
+++ /dev/null
@@ -1 +0,0 @@
-let flatMap f opt = Belt.Option.flatMapU opt (fun [@bs] x -> f x)
diff --git a/src/router.ts b/src/router.ts
new file mode 100644
index 0000000..abbdc65
--- /dev/null
+++ b/src/router.ts
@@ -0,0 +1,55 @@
+import * as Config from 'config'
+
+export enum Kind {
+ Form,
+ Timer,
+}
+
+export interface Route {
+ kind: Kind,
+ config: Config.Config
+}
+
+export function from(location: Location): Route {
+ const hash = location.hash.slice(1)
+ const parts = hash.split('?')
+ const path = parts[0]
+ const search = parts.length > 1 ? parts[1] : ''
+
+ const kind = path.startsWith('/timer') ? Kind.Timer : Kind.Form
+ let config = Config.init()
+ if (search.length > 0) {
+ search.split('&').forEach(entry => {
+ const xs = entry.split('=')
+ if (xs.length === 2) {
+ const key = xs[0]
+ const value = parseInt(xs[1])
+ if (key == 'prepare') config.prepare = value
+ else if (key == 'tabatas') config.tabatas = value
+ else if (key == 'cycles') config.cycles = value
+ else if (key == 'work') config.work = value
+ else if (key == 'rest') config.rest = value
+ }
+ })
+ const params = search.split('&')
+ }
+
+ return { kind, config }
+}
+
+export function toString(route: Route): string {
+ const path = route.kind === Kind.Form ? '/' : '/timer'
+ let query = ''
+ if (route.config !== undefined) {
+ const { prepare, tabatas, cycles, work, rest } = route.config
+ const params = [
+ `prepare=${prepare}`,
+ `tabatas=${tabatas}`,
+ `cycles=${cycles}`,
+ `work=${work}`,
+ `rest=${rest}`,
+ ].join('&')
+ query = `?${params}`
+ }
+ return `#${path}${query}`
+}
diff --git a/src/state.ts b/src/state.ts
new file mode 100644
index 0000000..3b390c5
--- /dev/null
+++ b/src/state.ts
@@ -0,0 +1,63 @@
+import * as Config from 'config'
+
+export enum Step {
+ Prepare,
+ Work,
+ Rest,
+ End,
+}
+
+export function prettyPrintStep(step: Step): string {
+ if (step === Step.Prepare)
+ return "Prepare"
+ else if (step === Step.Work)
+ return "Work"
+ else if (step === Step.Rest)
+ return "Rest"
+ else
+ return "End"
+}
+
+export interface State {
+ step: Step,
+ remaining: number,
+ tabata: number,
+ cycle: number,
+}
+
+export function getAt(config: Config.Config, elapsed: number): State {
+ const cycleDuration = config.work + config.rest
+ const tabataDuration = config.prepare + (config.cycles * cycleDuration)
+ if (elapsed >= tabataDuration * config.tabatas) {
+ return {
+ step: Step.End,
+ remaining: 0,
+ tabata: config.tabatas,
+ cycle: config.cycles,
+ }
+ } else {
+ const currentTabataElapsed = elapsed % tabataDuration
+ let step, remaining
+ if (currentTabataElapsed < config.prepare) {
+ step = Step.Prepare
+ remaining = config.prepare - currentTabataElapsed
+ } else {
+ const currentCycleElapsed = (currentTabataElapsed - config.prepare) % cycleDuration
+ if (currentCycleElapsed < config.work) {
+ step = Step.Work
+ remaining = config.work - currentCycleElapsed
+ } else {
+ step = Step.Rest
+ remaining = config.work + config.rest - currentCycleElapsed
+ }
+ }
+
+ const tabata = Math.floor(elapsed / tabataDuration) + 1
+ const cycle =
+ currentTabataElapsed < config.prepare
+ ? 1
+ : Math.floor((currentTabataElapsed - config.prepare) / cycleDuration) + 1
+
+ return { step, remaining, tabata, cycle }
+ }
+}
diff --git a/src/string.ml b/src/string.ml
deleted file mode 100644
index 335fdec..0000000
--- a/src/string.ml
+++ /dev/null
@@ -1 +0,0 @@
-external padStart : string -> int -> string -> string = "padStart" [@@bs.send]
diff --git a/src/view/form.ts b/src/view/form.ts
new file mode 100644
index 0000000..60e5f08
--- /dev/null
+++ b/src/view/form.ts
@@ -0,0 +1,47 @@
+import * as Config from 'config'
+import h from 'h'
+import * as Router from 'router'
+import * as Duration from 'duration'
+
+function labelledInput(
+ labelValue: string,
+ min: number,
+ value: number,
+ update: (n: number) => void
+): Element {
+ return h('label',
+ { className: 'g-Form__Label',
+ oninput: (e: Event) => {
+ if (e.target !== null) {
+ const target = e.target as HTMLInputElement
+ update(parseInt(target.value) || 0)
+ }
+ }
+ },
+ labelValue,
+ h('input', { className: 'g-Form__Input', type: 'number', min, value }))
+}
+
+export function view(config: Config.Config, showPage: (route: Router.Route) => void) {
+ const duration = document.createTextNode(Duration.prettyPrint(Config.getDuration(config)))
+ const wd = () => duration.textContent = Duration.prettyPrint(Config.getDuration(config))
+ return h('div',
+ { className: 'g-Layout__Page' },
+ h('header', { className: 'g-Layout__Header' }, 'Tabata timer'),
+ h('form',
+ { className: 'g-Form'
+ , onsubmit: (e: Event) => {
+ e.preventDefault()
+ const timerRoute = { kind: Router.Kind.Timer, config }
+ history.pushState({}, '', Router.toString(timerRoute))
+ showPage(timerRoute)
+ }},
+ labelledInput('prepare', 0, config.prepare, n => { config.prepare = n; wd()}),
+ labelledInput('tabatas', 1, config.tabatas, n => { config.tabatas = n; wd()}),
+ labelledInput('cycles', 1, config.cycles, n => { config.cycles = n; wd()}),
+ labelledInput('work', 5, config.work, n => { config.work = n; wd()}),
+ labelledInput('rest', 5, config.rest, n => { config.rest = n; wd()}),
+ h('div', { className: 'g-Form__Duration' }, 'duration', h('div', {}, duration)),
+ h('button', { className: 'g-Form__Start' }, 'start'))
+ )
+}
diff --git a/src/view/timer.ts b/src/view/timer.ts
new file mode 100644
index 0000000..ddcea71
--- /dev/null
+++ b/src/view/timer.ts
@@ -0,0 +1,158 @@
+import * as Config from 'config'
+import * as State from 'state'
+import * as Arc from 'arc'
+import * as Router from 'router'
+import * as Audio from 'audio'
+import h from 'h'
+
+let interval: number | undefined = undefined
+
+export function clearInterval() {
+ if (interval !== undefined) {
+ window.clearInterval(interval)
+ interval = undefined
+ }
+}
+
+export function view(config: Config.Config, showPage: (route: Router.Route) => void) {
+
+ const formUrl = `${Router.toString({ kind: Router.Kind.Form, config })}`
+ const duration = Config.getDuration(config)
+
+ // State
+ let isPlaying = true
+ let elapsed = 0
+ let state = State.getAt(config, elapsed)
+
+ // Elements
+ const section = h('section', { className: timerClass(state.step) })
+ const stepElt = document.createTextNode(State.prettyPrintStep(state.step))
+ const stepCountElt = document.createTextNode(stepCount(state))
+ const arcPathElt = h('path', { class: 'g-Timer__ArcProgress' })
+
+ const updateDom = () => {
+ const angle = elapsed / duration * 360
+ arcPathElt.setAttribute("d", Arc.describe(0, 0, 90, 0, angle))
+ section.className = timerClass(state.step)
+ stepElt.textContent = State.prettyPrintStep(state.step)
+ stepCountElt.textContent = stepCount(state)
+ Audio.playFromStep(config, state)
+ }
+
+ const quit = () => {
+ const formRoute = { kind: Router.Kind.Form, config }
+ history.pushState({}, '', Router.toString(formRoute))
+ showPage(formRoute)
+ }
+
+ const update = () => {
+ if (isPlaying) {
+ elapsed = elapsed + 1
+ state = State.getAt(config, elapsed)
+ elapsed > duration
+ ? quit()
+ : updateDom()
+ }
+ }
+
+ // Start timer
+ if (interval !== undefined) {
+ window.clearInterval(interval)
+ interval = undefined
+ }
+ interval = window.setInterval(update, 1000)
+
+ section.append(
+ h('div',
+ { className: 'g-Timer__Dial' },
+ h('svg',
+ { class: 'g-Timer__Arc',
+ viewBox: '-100 -100 200 200'
+ },
+ h('path',
+ { class: 'g-Timer__ArcTotal',
+ d: Arc.describe(0, 0, 90.0, 0, 359.999)
+ }
+ ),
+ ...arcPaths(config),
+ arcPathElt
+ ),
+ h('div', { className: 'g-Timer__Step' }, stepElt),
+ h('div', {}, stepCountElt)
+ ),
+ h('div',
+ { className: 'g-Timer__Buttons' },
+ h('button',
+ { className: 'g-Timer__Button',
+ onclick: (e: MouseEvent) => {
+ isPlaying = !isPlaying
+ const elt = e.target as HTMLElement
+ elt.textContent = isPlaying
+ ? 'pause'
+ : 'resume'
+ elt.className = isPlaying
+ ? 'g-Timer__Button'
+ : 'g-Timer__Button g-Timer__Button--Active'
+ }
+ },
+ 'pause'
+ ),
+ h('a',
+ { className: 'g-Timer__Button',
+ href: formUrl
+ },
+ 'quit'
+ )
+ )
+ )
+
+ return section
+}
+
+function arcPaths(config: Config.Config): Element[] {
+ const paths = []
+
+ let t = 0
+ const totalDuration = Config.getDuration(config)
+
+ let arc = (kind: string, duration: number): Element => {
+ const startAngle = 360 * t / totalDuration
+ const endAngle = 360 * (t + duration) / totalDuration
+
+ t += duration
+
+ return h('path',
+ { class: `g-Timer__Arc${kind}`,
+ d: Arc.describe(0, 0, 90.0, startAngle, endAngle)
+ }
+ )
+ }
+
+ for (let tabata = 0; tabata < config.tabatas; tabata++) {
+ paths.push(arc('Prepare', config.prepare))
+ for (let cycle = 0; cycle < config.cycles; cycle++) {
+ paths.push(arc('Work', config.work))
+ paths.push(arc('Rest', config.rest))
+ }
+ }
+
+ return paths
+}
+
+function timerClass(step: State.Step): string {
+ if (step === State.Step.Work) {
+ return 'g-Layout__Page g-Timer g-Timer--Work'
+ } else if (step === State.Step.Rest) {
+ return 'g-Layout__Page g-Timer g-Timer--Rest'
+ } else {
+ return 'g-Layout__Page g-Timer g-Timer--Prepare'
+ }
+}
+
+function stepCount(state: State.State): string {
+ if (state.step === State.Step.Work || state.step === State.Step.Rest) {
+ return `#${state.tabata.toString()}.${state.cycle.toString()}`
+ } else {
+ return `#${state.tabata.toString()}`
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..3e7f32b
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "module": "amd",
+ "target": "es5",
+ "baseUrl": "src",
+ "outFile": "public/main.js",
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "removeComments": true,
+ "preserveConstEnums": true
+ },
+ "include": ["src/**/*"]
+}