aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoris2021-05-13 14:50:51 +0200
committerJoris2021-05-13 14:58:26 +0200
commit221b6451fb4f8559a10e7fefebd13ce125ef29d0 (patch)
tree3ab337b7b2d40e8235f887046a580b0850540f11 /src
parent5c636f11cdfed82634ee572645d765b704941b68 (diff)
Rewrite in TypeScript
BuckleScript is no longer maintained. Choose a widely used techno that will still be maintained in the following years.
Diffstat (limited to 'src')
-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
26 files changed, 460 insertions, 501 deletions
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()}`
+ }
+}