From f9e7e819a0a673befb11b24404efeb9d6644bceb Mon Sep 17 00:00:00 2001 From: Joris Date: Thu, 20 May 2021 09:43:02 +0200 Subject: Provide named exercices --- public/main.css | 137 +++++++++++++++++++++++++++++------------ src/audio.ts | 14 ++--- src/config.ts | 8 +-- src/router.ts | 29 ++++++--- src/state.ts | 87 -------------------------- src/step.ts | 74 +++++++++++++++++++++++ src/view/form.ts | 178 +++++++++++++++++++++++++++++++++++++++++++++--------- src/view/timer.ts | 87 +++++++++++++------------- 8 files changed, 401 insertions(+), 213 deletions(-) delete mode 100644 src/state.ts create mode 100644 src/step.ts diff --git a/public/main.css b/public/main.css index deec437..1461fef 100644 --- a/public/main.css +++ b/public/main.css @@ -11,9 +11,13 @@ html { :root { --color-active: #F3E87F; - --color-header: #333333; + --color-active-hover: #FBEF81; + --color-header: brown; + --color-header-darker: #822929; --color-action: #333333; - --color-action-darker: #222222; + --color-action-darker: #111111; + --color-action-hover: #555555; + --color-label: #333333; --color-prepare: #3B6EDC; --color-pause: #888888; --color-warm-up: #C679D9; @@ -21,6 +25,9 @@ html { --color-rest: #B15B5B; --color-timer-arc-total: #222222; --color-timer-hover: #DDEEDD; + --color-focus: orange; + --color-title: brown; + --color-input-border: #CCCCCC; --base-font-size: 18px; } @@ -51,6 +58,7 @@ body { color: white; padding: 1rem 2rem; font-size: 2rem; + border-bottom: 0.1rem solid var(--color-header-darker); } /* Animation */ @@ -80,42 +88,59 @@ body { display: flex; flex-direction: column; align-items: center; - padding-top: 5rem; + padding-top: 2rem; background-color: white; } -.g-Form__Label { +.g-Form__Section { display: flex; flex-direction: column; align-items: center; - margin-bottom: 1rem; - text-align: center; - font-size: 1.3rem; + padding: 1rem 0; } -.g-Form__Input { - display: block; +.g-Form__Tabatas { + display: flex; + flex-direction: column; + width: 17rem; +} + +.g-Form__Line { + display: flex; + align-items: flex-end; + margin-bottom: 2rem; +} + +.g-Form__Title { text-align: center; - margin-top: 0.5rem; - font-size: 1.3rem; - width: 10rem; } -.g-Form__Duration { +.g-Form__Label { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 1rem; text-align: center; - font-size: 1.5rem; - margin-top: 1rem; + margin: 0 1rem; + color: var(--color-label); + line-height: 2rem; +} + +.g-Form__Tabata { + display: grid; + grid-template-columns: auto auto; + grid-gap: 1rem; + margin-bottom: 1rem; +} + +.g-Form__Operator { + padding-bottom: 0.3rem; } -.g-Form__Start { +.g-Form__Duration { + text-align: 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; + margin-bottom: 1rem; } /* Timer */ @@ -156,6 +181,9 @@ body { align-items: center; justify-content: center; flex-direction: column; + white-space: pre-wrap; + text-align: center; + line-height: 6rem; width: 100%; height: 100%; @@ -213,10 +241,6 @@ body { stroke-width: 18; } -.g-Timer__Step { - margin-bottom: 2rem; -} - .g-Timer__Buttons { display: flex; justify-content: space-around; @@ -226,26 +250,65 @@ body { height: 100%; } -.g-Timer__Button { - display: flex; +/* Titles */ + +h1 { + font-weight: normal; + color: var(--color-title); + margin-top: 0; + text-decoration: underline; +} + +/* Input */ + +.g-Input { + display: block; + font-size: inherit; + width: 10rem; + padding: 0.3rem; + border: 0.1rem solid var(--color-input-border); +} + +.g-Input:focus { + border-color: var(--color-focus); +} + +/* Button */ + +.g-Button { + display: inline-flex; justify-content: center; align-items: center; - font-size: 1.5rem; + text-decoration: none; + font-size: inherit; background-color: var(--color-action); - border: 3px solid var(--color-action-darker); + border: 0.1rem solid var(--color-action); color: white; padding: 0.5rem 0.8rem; - width: 10rem; cursor: pointer; - text-align: center; - text-decoration: none; } -.g-Timer__Button:not(:last-child) { - margin-right: 2rem; +.g-Button:hover { + background-color: var(--color-action-hover); +} + +.g-Button:focus { + border-color: var(--color-focus); } -.g-Timer__Button--Active { +.g-Button--Active { background-color: var(--color-active); color: black; } + +.g-Button--Active:hover { + background-color: var(--color-active-hover); + color: black; +} + +/* List */ + +.g-List { + margin: 0; + padding: 0; +} diff --git a/src/audio.ts b/src/audio.ts index 4147164..350093a 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -1,21 +1,21 @@ import * as Config from 'config' -import * as State from 'state' +import * as Step from 'step' 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.WarmUp && state.remaining === config.warmup) { +export function playFromStep(config: Config.Config, step: Step.Step, elapsed: number) { + if (step.name === 'warmup' && step.remaining === config.warmup) { start.play() - } else if (state.step === State.Step.Work && state.remaining === config.work) { + } else if (step.name === 'work' && step.remaining === config.work) { start.play() - } else if (state.step === State.Step.Rest && state.remaining === config.rest) { + } else if (step.name === 'rest' && step.remaining === config.rest) { stop.play() - } else if (state.step === State.Step.Prepare && state.remaining === config.prepare && state.elapsed > 0) { + } else if (step.name === 'prepare' && step.remaining === config.prepare && elapsed > 0) { endTabata.play() - } else if (state.step === State.Step.End && state.elapsed === Config.getDuration(config)) { + } else if (step.name === 'end' && elapsed === Config.getDuration(config)) { endTraining.play() } } diff --git a/src/config.ts b/src/config.ts index d1d369e..5cd23e6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ export interface Config { warmup: number; prepare : number; - tabatas : number; + tabatas : string[]; cycles : number; work : number; rest : number; @@ -9,8 +9,8 @@ export interface Config { export function init(): Config { return { - warmup: 120, - tabatas: 4, + warmup: 180, + tabatas: [], prepare: 10, cycles: 8, work: 20, @@ -19,5 +19,5 @@ export function init(): Config { } export function getDuration(c: Config): number { - return c.warmup + c.tabatas * (c.prepare + (c.cycles * (c.work + c.rest))) + return c.warmup + c.tabatas.length * (c.prepare + (c.cycles * (c.work + c.rest))) } diff --git a/src/router.ts b/src/router.ts index 2d229b0..bcdf8eb 100644 --- a/src/router.ts +++ b/src/router.ts @@ -17,13 +17,12 @@ export function from(location: Location): Route { const xs = entry.split('=') if (xs.length === 2) { const key = xs[0] - const value = parseInt(xs[1]) - if (key == 'warmup') config.warmup = value - else if (key == 'tabatas') config.tabatas = value - else if (key == 'prepare') config.prepare = value - else if (key == 'cycles') config.cycles = value - else if (key == 'work') config.work = value - else if (key == 'rest') config.rest = value + if (key == 'warmup') config.warmup = parseInt(xs[1]) + else if (key == 'tabatas') config.tabatas = decodeTabatas(xs[1]) + else if (key == 'prepare') config.prepare = parseInt(xs[1]) + else if (key == 'cycles') config.cycles = parseInt(xs[1]) + else if (key == 'work') config.work = parseInt(xs[1]) + else if (key == 'rest') config.rest = parseInt(xs[1]) } }) const params = search.split('&') @@ -39,13 +38,23 @@ export function toString(route: Route): string { const { warmup, tabatas, prepare, cycles, work, rest } = route.config const params = [ `warmup=${warmup}`, - `tabatas=${tabatas}`, `prepare=${prepare}`, `cycles=${cycles}`, `work=${work}`, `rest=${rest}`, - ].join('&') - query = `?${params}` + ] + if(tabatas.length > 0) { + params.push(`tabatas=${encodeTabatas(tabatas)}`) + } + query = `?${params.join('&')}` } return `#${path}${query}` } + +function encodeTabatas(xs: string[]): string { + return encodeURIComponent(xs.join(',')) +} + +function decodeTabatas(str: string): string[] { + return decodeURIComponent(str).split(',').map(t => t.trim()) +} diff --git a/src/state.ts b/src/state.ts deleted file mode 100644 index a0348f0..0000000 --- a/src/state.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as Config from 'config' - -export enum Step { - WarmUp, - Prepare, - Work, - Rest, - End, -} - -export function prettyPrintStep(step: Step): string { - if (step === Step.WarmUp) - return 'Warm Up' - 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, - info: string, - elapsed: number, -} - -export function getAt(config: Config.Config, elapsed: number): State { - if (elapsed < config.warmup) { - return { - step: Step.WarmUp, - remaining: config.warmup - elapsed, - info: '', - elapsed - } - } - - const tabataElapsed = elapsed - config.warmup - - const cycleDuration = config.work + config.rest - const tabataDuration = config.prepare + (config.cycles * cycleDuration) - - if (tabataElapsed >= tabataDuration * config.tabatas) { - return { - step: Step.End, - remaining: 0, - info: '', - elapsed - } - } - - const currentTabataElapsed = tabataElapsed % 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(tabataElapsed / tabataDuration) + 1 - const cycle = - currentTabataElapsed < config.prepare - ? 1 - : Math.floor((currentTabataElapsed - config.prepare) / cycleDuration) + 1 - const info = stepCount(step, tabata, cycle) - - return { step, remaining, info, elapsed } -} - -function stepCount(step: Step, tabata: number, cycle: number): string { - if (step === Step.Work || step === Step.Rest) { - return `#${tabata.toString()}.${cycle.toString()}` - } else { - return `#${tabata.toString()}` - } -} diff --git a/src/step.ts b/src/step.ts new file mode 100644 index 0000000..13ca183 --- /dev/null +++ b/src/step.ts @@ -0,0 +1,74 @@ +import * as Config from 'config' + +export type Step + = { name: 'warmup', remaining: number } + | { name: 'prepare', tabata: string, remaining: number } + | { name: 'work', tabata: string, cycle: number, remaining: number } + | { name: 'rest', tabata: string, cycle: number, remaining: number } + | { name: 'end' } + +export function prettyPrint(step: Step): string { + switch (step.name) { + case 'warmup': + return 'Warm Up' + case 'prepare': + return `${step.tabata}\nPreparation` + case 'work': + return `${step.tabata}\nWork ${step.cycle}` + case 'rest': + return `${step.tabata}\nRest ${step.cycle}` + case 'end': + return 'End!' + } +} + +export function getAt(config: Config.Config, elapsed: number): Step { + if (elapsed < config.warmup) { + return { + name: 'warmup', + remaining: config.warmup - elapsed + } + } + + const tabataElapsed = elapsed - config.warmup + + const cycleDuration = config.work + config.rest + const tabataDuration = config.prepare + (config.cycles * cycleDuration) + + if (tabataElapsed >= tabataDuration * config.tabatas.length) { + return { name: 'end' } + } + + const tabata = config.tabatas[Math.floor(tabataElapsed / tabataDuration)] + const currentTabataElapsed = tabataElapsed % tabataDuration + + if (currentTabataElapsed < config.prepare) { + return { + name: 'prepare', + tabata, + remaining: config.prepare - currentTabataElapsed + } + } else { + const currentCycleElapsed = (currentTabataElapsed - config.prepare) % cycleDuration + const cycle = + currentTabataElapsed < config.prepare + ? 1 + : Math.floor((currentTabataElapsed - config.prepare) / cycleDuration) + 1 + + if (currentCycleElapsed < config.work) { + return { + name: 'work', + tabata, + cycle, + remaining: config.work - currentCycleElapsed + } + } else { + return { + name: 'rest', + tabata, + cycle, + remaining: config.work + config.rest - currentCycleElapsed + } + } + } +} diff --git a/src/view/form.ts b/src/view/form.ts index b0d5827..a5e7253 100644 --- a/src/view/form.ts +++ b/src/view/form.ts @@ -3,25 +3,6 @@ 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)) @@ -36,13 +17,156 @@ export function view(config: Config.Config, showPage: (route: Router.Route) => v history.pushState({}, '', Router.toString(timerRoute)) showPage(timerRoute) }}, - labelledInput('Warm Up', 0, config.warmup, n => { config.warmup = n; wd()}), - labelledInput('Tabatas', 1, config.tabatas, n => { config.tabatas = n; wd()}), - labelledInput('Prepare', 0, config.prepare, n => { config.prepare = 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')) + h('section', + { className: 'g-Form__Section' }, + numberInput('Warm Up', 0, config.warmup, n => { config.warmup = n; wd()}) + ), + h('section', + { className: 'g-Form__Section' }, + h('h1', { className: 'g-Form__Title' }, 'Tabatas'), + h('div', + { className: 'g-Form__Line' }, + numberInput('Preparation', 0, config.prepare, n => { config.prepare = n; wd()}), + operator('+'), + numberInput('Cycles', 1, config.cycles, n => { config.cycles = n; wd()}), + operator('× ('), + numberInput('Work', 5, config.work, n => { config.work = n; wd()}), + operator('+'), + numberInput('Rest', 5, config.rest, n => { config.rest = n; wd()}), + operator(')') + ), + tabatasSection( + config.tabatas, + (str: string) => { config.tabatas.push(str); wd() }, + (index: number, str: string) => { config.tabatas[index] = str; wd() }, + (index: number) => { config.tabatas.splice(index, 1); wd() } + ) + ), + h('section', + { className: 'g-Form__Section' }, + h('h1', { className: 'g-Title' }, 'Duration'), + h('div', { className: 'g-Form__Duration' }, duration) + ), + h('button', { className: 'g-Button' }, 'Start') + ) + ) +} + +function operator(str: string): Element { + return h('span', { className: 'g-Form__Operator' }, str) +} + +function tabatasSection( + init: string[], + onAdd: (str: string) => void, + onUpdate: (index: number, str: string) => void, + onRemove: (inedx: number) => void +) { + const tabatas = h('ol', { className: 'g-List' }) + let counter = init.length + let removedIndexes: number[] = [] + let adjustIndex = (index: number) => index - removedIndexes.filter(i => i < index).length + + init.forEach((initStr, index) => { + const { tabata } = tabataInput( + initStr, + (str: string) => onUpdate(adjustIndex(index), str), + () => { + onRemove(adjustIndex(index)) + tabatas.removeChild(tabata) + removedIndexes.push(index) + } + ) + tabatas.appendChild(tabata) + }) + + return h('div', + { className: 'g-Form__Tabatas' }, + tabatas, + h('input', + { type: 'button', + value: 'Add', + className: 'g-Button', + onclick: (e: Event) => { + let index = counter++ + const txt = `Exercise ${adjustIndex(index) + 1}` + onAdd(txt) + let { tabata, textInput } = tabataInput( + txt, + (str: string) => onUpdate(adjustIndex(index), str), + () => { + onRemove(adjustIndex(index)) + tabatas.removeChild(tabata) + removedIndexes.push(index) + } + ) + tabatas.appendChild(tabata) + textInput.select() + } + } + ) + ) +} + +interface TabataInput { + tabata: Element, + textInput: HTMLInputElement, +} + +function tabataInput( + init: string, + onUpdate: (str: string) => void, + onRemove: () => void +): TabataInput { + const textInput = h('input', + { value: init, + className: 'g-Input', + required: 'required', + oninput: (e: Event) => { + if (e.target !== null) { + const target = e.target as HTMLInputElement + onUpdate(target.value.trim()) + } + } + } + ) as HTMLInputElement + + const tabata = h('li', + { className: 'g-Form__Tabata'}, + textInput, + h('input', + { type: 'button', + value: 'Remove', + className: 'g-Button', + onclick: (e: Event) => onRemove() + } + ) ) + + return { tabata, textInput } +} + +function numberInput( + 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-Input', + type: 'number', + required: 'required', + min, + value + })) } diff --git a/src/view/timer.ts b/src/view/timer.ts index 07b5db3..3cdf0d7 100644 --- a/src/view/timer.ts +++ b/src/view/timer.ts @@ -1,5 +1,5 @@ import * as Config from 'config' -import * as State from 'state' +import * as Step from 'step' import * as Arc from 'arc' import * as Router from 'router' import * as Audio from 'audio' @@ -16,29 +16,35 @@ export function clearInterval() { } } +interface State { + isPlaying: boolean, + elapsed: number +} + export function view(config: Config.Config, showPage: (route: Router.Route) => void) { const formUrl = `${Router.toString({ name: 'form', config })}` const duration = Config.getDuration(config) // State - let isPlaying = true - let elapsed = 0 - let state = State.getAt(config, elapsed) + let state = { + isPlaying: true, + elapsed: 0 + } // Elements - const section = h('section', { className: timerClass(state.step) }) - const stepElt = document.createTextNode(State.prettyPrintStep(state.step)) - const stepInfoElt = document.createTextNode(state.info) + const initStep = Step.getAt(config, state.elapsed) + const section = h('section', { className: timerClass(initStep) }) + const stepElt = document.createTextNode(Step.prettyPrint(initStep)) const arcPathElt = h('path', { class: 'g-Timer__ArcProgress' }) const updateDom = () => { - const angle = Math.min(359.999, elapsed / duration * 360) + const step = Step.getAt(config, state.elapsed) + const angle = Math.min(359.999, state.elapsed / duration * 360) arcPathElt.setAttribute("d", Arc.describe(0, 0, 90, 0, angle)) - section.className = timerClass(state.step) - stepElt.textContent = State.prettyPrintStep(state.step) - stepInfoElt.textContent = state.info - Audio.playFromStep(config, state) + section.className = timerClass(step) + stepElt.textContent = Step.prettyPrint(step) + Audio.playFromStep(config, step, state.elapsed) } const quit = () => { @@ -49,10 +55,9 @@ export function view(config: Config.Config, showPage: (route: Router.Route) => v } const update = () => { - if (isPlaying) { - elapsed = elapsed + 1 - state = State.getAt(config, elapsed) - elapsed >= duration + endDuration + if (state.isPlaying) { + state.elapsed = state.elapsed + 1 + state.elapsed >= duration + endDuration ? quit() : updateDom() } @@ -66,7 +71,7 @@ export function view(config: Config.Config, showPage: (route: Router.Route) => v interval = window.setInterval(update, 1000) // Play initial audio - Audio.playFromStep(config, state) + Audio.playFromStep(config, initStep, state.elapsed) section.append( h('div', @@ -83,31 +88,30 @@ export function view(config: Config.Config, showPage: (route: Router.Route) => v ...arcPaths(config), arcPathElt ), - h('div', { className: 'g-Timer__Step' }, stepElt), - h('div', {}, stepInfoElt) + stepElt ), h('div', { className: 'g-Timer__Buttons' }, h('button', - { className: 'g-Timer__Button', + { className: 'g-Button', onclick: (e: MouseEvent) => { - isPlaying = !isPlaying + state.isPlaying = !state.isPlaying const elt = e.target as HTMLElement - elt.textContent = isPlaying + elt.textContent = state.isPlaying ? 'pause' : 'resume' - elt.className = isPlaying - ? 'g-Timer__Button' - : 'g-Timer__Button g-Timer__Button--Active' + elt.className = state.isPlaying + ? 'g-Button' + : 'g-Button g-Button--Active' } }, - 'pause' + 'Pause' ), h('a', - { className: 'g-Timer__Button', + { className: 'g-Button', href: formUrl }, - 'quit' + 'Quit' ) ) ) @@ -123,7 +127,7 @@ function arcPaths(config: Config.Config): Element[] { let arc = (kind: string, duration: number): Element => { const startAngle = 360 * t / totalDuration - const endAngle = 360 * (t + duration) / totalDuration + const endAngle = Math.min(360 * (t + duration) / totalDuration, 359.999) t += duration @@ -138,7 +142,7 @@ function arcPaths(config: Config.Config): Element[] { paths.push(arc('WarmUp', config.warmup)) } - for (let tabata = 0; tabata < config.tabatas; tabata++) { + for (let tabata = 0; tabata < config.tabatas.length; tabata++) { paths.push(arc('Prepare', config.prepare)) for (let cycle = 0; cycle < config.cycles; cycle++) { paths.push(arc('Work', config.work)) @@ -149,16 +153,17 @@ function arcPaths(config: Config.Config): Element[] { return paths } -function timerClass(step: State.Step): string { - if (step === State.Step.WarmUp) { - return 'g-Layout__Page g-Timer g-Timer--WarmUp' - } else 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 if (step === State.Step.Prepare) { - return 'g-Layout__Page g-Timer g-Timer--Prepare' - } else { - return 'g-Layout__Page g-Timer' +function timerClass(step: Step.Step): string { + switch (step.name) { + case 'warmup': + return 'g-Layout__Page g-Timer g-Timer--WarmUp' + case 'prepare': + return 'g-Layout__Page g-Timer g-Timer--Prepare' + case 'work': + return 'g-Layout__Page g-Timer g-Timer--Work' + case 'rest': + return 'g-Layout__Page g-Timer g-Timer--Rest' + case 'end': + return 'g-Layout__Page g-Timer' } } -- cgit v1.2.3