diff options
-rw-r--r-- | Makefile | 22 | ||||
-rw-r--r-- | README.md | 30 | ||||
-rwxr-xr-x | bin/watch.sh | 8 | ||||
-rw-r--r-- | bsconfig.json | 22 | ||||
-rwxr-xr-x | deploy | 12 | ||||
-rwxr-xr-x | dev | 27 | ||||
-rw-r--r-- | package-lock.json | 150 | ||||
-rw-r--r-- | package.json | 14 | ||||
-rw-r--r-- | public/index.html | 42 | ||||
-rw-r--r-- | public/main.css | 114 | ||||
-rw-r--r-- | public/sound/end-tabata.mp3 | bin | 0 -> 34516 bytes | |||
-rw-r--r-- | public/sound/end-training.mp3 | bin | 0 -> 54090 bytes | |||
-rw-r--r-- | public/sound/start.mp3 | bin | 0 -> 36013 bytes | |||
-rw-r--r-- | public/sound/stop.mp3 | bin | 0 -> 37958 bytes | |||
-rw-r--r-- | public/sounds/c3.mp3 | bin | 47469 -> 0 bytes | |||
-rw-r--r-- | public/sounds/c4.mp3 | bin | 57357 -> 0 bytes | |||
-rw-r--r-- | public/sounds/c5.mp3 | bin | 65742 -> 0 bytes | |||
-rw-r--r-- | rollup.config.js | 13 | ||||
-rw-r--r-- | shell.nix | 21 | ||||
-rw-r--r-- | src/Dom/CreateElement.ml | 72 | ||||
-rw-r--r-- | src/Dom/Document.ml | 14 | ||||
-rw-r--r-- | src/Dom/Element.ml | 44 | ||||
-rw-r--r-- | src/Dom/Event.ml | 3 | ||||
-rw-r--r-- | src/Dom/EventTarget.ml | 4 | ||||
-rw-r--r-- | src/Model/config.ml | 12 | ||||
-rw-r--r-- | src/Model/step.ml | 40 | ||||
-rw-r--r-- | src/View/configView.ml | 83 | ||||
-rw-r--r-- | src/View/timerView.ml | 123 | ||||
-rw-r--r-- | src/animation.ml | 27 | ||||
-rw-r--r-- | src/arc.ml | 23 | ||||
-rw-r--r-- | src/arc.ts | 37 | ||||
-rw-r--r-- | src/audio.ml | 34 | ||||
-rw-r--r-- | src/audio.ts | 19 | ||||
-rw-r--r-- | src/config.ts | 21 | ||||
-rw-r--r-- | src/duration.ml | 6 | ||||
-rw-r--r-- | src/duration.ts | 9 | ||||
-rw-r--r-- | src/h.ts | 30 | ||||
-rw-r--r-- | src/main.ml | 14 | ||||
-rw-r--r-- | src/main.ts | 21 | ||||
-rw-r--r-- | src/option.ml | 1 | ||||
-rw-r--r-- | src/router.ts | 55 | ||||
-rw-r--r-- | src/state.ts | 63 | ||||
-rw-r--r-- | src/string.ml | 1 | ||||
-rw-r--r-- | src/view/form.ts | 47 | ||||
-rw-r--r-- | src/view/timer.ts | 158 | ||||
-rw-r--r-- | tsconfig.json | 13 |
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 {} \; @@ -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 -} @@ -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 @@ -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-simple-typescript-web-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 Binary files differnew file mode 100644 index 0000000..860c703 --- /dev/null +++ b/public/sound/end-tabata.mp3 diff --git a/public/sound/end-training.mp3 b/public/sound/end-training.mp3 Binary files differnew file mode 100644 index 0000000..8af4c27 --- /dev/null +++ b/public/sound/end-training.mp3 diff --git a/public/sound/start.mp3 b/public/sound/start.mp3 Binary files differnew file mode 100644 index 0000000..05aee23 --- /dev/null +++ b/public/sound/start.mp3 diff --git a/public/sound/stop.mp3 b/public/sound/stop.mp3 Binary files differnew file mode 100644 index 0000000..c618f28 --- /dev/null +++ b/public/sound/stop.mp3 diff --git a/public/sounds/c3.mp3 b/public/sounds/c3.mp3 Binary files differdeleted file mode 100644 index 13e661a..0000000 --- a/public/sounds/c3.mp3 +++ /dev/null diff --git a/public/sounds/c4.mp3 b/public/sounds/c4.mp3 Binary files differdeleted file mode 100644 index 0266119..0000000 --- a/public/sounds/c4.mp3 +++ /dev/null diff --git a/public/sounds/c5.mp3 b/public/sounds/c5.mp3 Binary files differdeleted file mode 100644 index 8ff926e..0000000 --- a/public/sounds/c5.mp3 +++ /dev/null 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() - ] -}; @@ -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/**/*"] +} |